fast-agent-mcp 0.3.13__py3-none-any.whl → 0.3.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fast-agent-mcp might be problematic. Click here for more details.
- fast_agent/agents/llm_agent.py +59 -37
- fast_agent/agents/llm_decorator.py +13 -2
- fast_agent/agents/mcp_agent.py +21 -5
- fast_agent/agents/tool_agent.py +41 -29
- fast_agent/agents/workflow/router_agent.py +2 -1
- fast_agent/cli/commands/check_config.py +48 -1
- fast_agent/config.py +65 -2
- fast_agent/constants.py +3 -0
- fast_agent/context.py +42 -9
- fast_agent/core/fastagent.py +14 -1
- fast_agent/core/logging/listeners.py +1 -1
- fast_agent/core/validation.py +31 -33
- fast_agent/event_progress.py +2 -3
- fast_agent/human_input/form_fields.py +4 -1
- fast_agent/interfaces.py +12 -2
- fast_agent/llm/fastagent_llm.py +31 -0
- fast_agent/llm/model_database.py +2 -2
- fast_agent/llm/model_factory.py +8 -1
- fast_agent/llm/provider_key_manager.py +1 -0
- fast_agent/llm/provider_types.py +1 -0
- fast_agent/llm/request_params.py +3 -1
- fast_agent/mcp/mcp_aggregator.py +313 -40
- fast_agent/mcp/mcp_connection_manager.py +39 -9
- fast_agent/mcp/prompt_message_extended.py +2 -2
- fast_agent/mcp/skybridge.py +45 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/transport_tracking.py +37 -3
- fast_agent/mcp/types.py +24 -0
- fast_agent/resources/examples/workflows/router.py +1 -0
- fast_agent/resources/setup/fastagent.config.yaml +7 -1
- fast_agent/ui/console_display.py +946 -84
- fast_agent/ui/elicitation_form.py +23 -1
- fast_agent/ui/enhanced_prompt.py +153 -58
- fast_agent/ui/interactive_prompt.py +57 -34
- fast_agent/ui/markdown_truncator.py +942 -0
- fast_agent/ui/mcp_display.py +110 -29
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/rich_progress.py +4 -1
- fast_agent/ui/streaming_buffer.py +449 -0
- {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/METADATA +4 -3
- {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/RECORD +44 -38
- {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/licenses/LICENSE +0 -0
fast_agent/ui/console_display.py
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from contextlib import contextmanager
|
|
1
3
|
from enum import Enum
|
|
2
4
|
from json import JSONDecodeError
|
|
3
|
-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Union
|
|
4
6
|
|
|
5
7
|
from mcp.types import CallToolResult
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.markdown import Markdown
|
|
6
10
|
from rich.panel import Panel
|
|
7
11
|
from rich.text import Text
|
|
8
12
|
|
|
13
|
+
from fast_agent.config import Settings
|
|
9
14
|
from fast_agent.constants import REASONING
|
|
10
15
|
from fast_agent.ui import console
|
|
16
|
+
from fast_agent.ui.markdown_truncator import MarkdownTruncator
|
|
11
17
|
from fast_agent.ui.mcp_ui_utils import UILink
|
|
12
18
|
from fast_agent.ui.mermaid_utils import (
|
|
13
19
|
MermaidDiagram,
|
|
@@ -15,12 +21,21 @@ from fast_agent.ui.mermaid_utils import (
|
|
|
15
21
|
detect_diagram_type,
|
|
16
22
|
extract_mermaid_diagrams,
|
|
17
23
|
)
|
|
24
|
+
from fast_agent.ui.plain_text_truncator import PlainTextTruncator
|
|
18
25
|
|
|
19
26
|
if TYPE_CHECKING:
|
|
20
27
|
from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
|
|
28
|
+
from fast_agent.mcp.skybridge import SkybridgeServerConfig
|
|
21
29
|
|
|
22
30
|
CODE_STYLE = "native"
|
|
23
31
|
|
|
32
|
+
MARKDOWN_STREAM_TARGET_RATIO = 0.7
|
|
33
|
+
MARKDOWN_STREAM_REFRESH_PER_SECOND = 4
|
|
34
|
+
MARKDOWN_STREAM_HEIGHT_FUDGE = 1
|
|
35
|
+
PLAIN_STREAM_TARGET_RATIO = 0.9
|
|
36
|
+
PLAIN_STREAM_REFRESH_PER_SECOND = 20
|
|
37
|
+
PLAIN_STREAM_HEIGHT_FUDGE = 1
|
|
38
|
+
|
|
24
39
|
|
|
25
40
|
class MessageType(Enum):
|
|
26
41
|
"""Types of messages that can be displayed."""
|
|
@@ -82,33 +97,104 @@ def _prepare_markdown_content(content: str, escape_xml: bool = True) -> str:
|
|
|
82
97
|
This ensures XML/HTML tags are displayed as visible text rather than
|
|
83
98
|
being interpreted as markup by the markdown renderer.
|
|
84
99
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
Uses markdown-it parser to properly identify code regions, avoiding
|
|
101
|
+
the issues with regex-based approaches (e.g., backticks inside fenced
|
|
102
|
+
code blocks being misidentified as inline code).
|
|
88
103
|
"""
|
|
89
104
|
if not escape_xml or not isinstance(content, str):
|
|
90
105
|
return content
|
|
91
106
|
|
|
107
|
+
# Import markdown-it for proper parsing
|
|
108
|
+
from markdown_it import MarkdownIt
|
|
109
|
+
|
|
110
|
+
# Parse the markdown to identify code regions
|
|
111
|
+
parser = MarkdownIt()
|
|
112
|
+
try:
|
|
113
|
+
tokens = parser.parse(content)
|
|
114
|
+
except Exception:
|
|
115
|
+
# If parsing fails, fall back to escaping everything
|
|
116
|
+
# (better safe than corrupting content)
|
|
117
|
+
result = content
|
|
118
|
+
for char, replacement in HTML_ESCAPE_CHARS.items():
|
|
119
|
+
result = result.replace(char, replacement)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
# Collect protected ranges from tokens
|
|
92
123
|
protected_ranges = []
|
|
124
|
+
lines = content.split("\n")
|
|
125
|
+
|
|
126
|
+
def _flatten_tokens(tokens):
|
|
127
|
+
"""Recursively flatten token tree."""
|
|
128
|
+
for token in tokens:
|
|
129
|
+
yield token
|
|
130
|
+
if token.children:
|
|
131
|
+
yield from _flatten_tokens(token.children)
|
|
132
|
+
|
|
133
|
+
# Process all tokens to find code blocks and inline code
|
|
134
|
+
for token in _flatten_tokens(tokens):
|
|
135
|
+
if token.map is not None:
|
|
136
|
+
# Block-level tokens with line mapping (fence, code_block)
|
|
137
|
+
if token.type in ("fence", "code_block"):
|
|
138
|
+
start_line = token.map[0]
|
|
139
|
+
end_line = token.map[1]
|
|
140
|
+
start_pos = sum(len(line) + 1 for line in lines[:start_line])
|
|
141
|
+
end_pos = sum(len(line) + 1 for line in lines[:end_line])
|
|
142
|
+
protected_ranges.append((start_pos, end_pos))
|
|
143
|
+
|
|
144
|
+
# Inline code tokens don't have map, but have content
|
|
145
|
+
if token.type == "code_inline":
|
|
146
|
+
# For inline code, we need to find its position in the source
|
|
147
|
+
# The token has the content, but we need to search for it
|
|
148
|
+
# We'll look for the pattern `content` in the content string
|
|
149
|
+
code_content = token.content
|
|
150
|
+
if code_content:
|
|
151
|
+
# Search for this inline code in the content
|
|
152
|
+
# We need to account for the backticks: `content`
|
|
153
|
+
pattern = f"`{code_content}`"
|
|
154
|
+
start = 0
|
|
155
|
+
while True:
|
|
156
|
+
pos = content.find(pattern, start)
|
|
157
|
+
if pos == -1:
|
|
158
|
+
break
|
|
159
|
+
# Check if this position is already in a protected range
|
|
160
|
+
in_protected = any(s <= pos < e for s, e in protected_ranges)
|
|
161
|
+
if not in_protected:
|
|
162
|
+
protected_ranges.append((pos, pos + len(pattern)))
|
|
163
|
+
start = pos + len(pattern)
|
|
164
|
+
|
|
165
|
+
# Check for incomplete code blocks (streaming scenario)
|
|
166
|
+
# Count opening vs closing fences
|
|
93
167
|
import re
|
|
94
168
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
for match in re.finditer(code_block_pattern, content):
|
|
98
|
-
protected_ranges.append((match.start(), match.end()))
|
|
169
|
+
fence_pattern = r"^```"
|
|
170
|
+
fences = list(re.finditer(fence_pattern, content, re.MULTILINE))
|
|
99
171
|
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
172
|
+
# If we have an odd number of fences, the last one is incomplete
|
|
173
|
+
if len(fences) % 2 == 1:
|
|
174
|
+
# Protect from the last fence to the end
|
|
175
|
+
last_fence_pos = fences[-1].start()
|
|
176
|
+
# Only add if not already protected
|
|
177
|
+
in_protected = any(s <= last_fence_pos < e for s, e in protected_ranges)
|
|
178
|
+
if not in_protected:
|
|
179
|
+
protected_ranges.append((last_fence_pos, len(content)))
|
|
104
180
|
|
|
181
|
+
# Sort and merge overlapping ranges
|
|
105
182
|
protected_ranges.sort(key=lambda x: x[0])
|
|
106
183
|
|
|
184
|
+
# Merge overlapping ranges
|
|
185
|
+
merged_ranges = []
|
|
186
|
+
for start, end in protected_ranges:
|
|
187
|
+
if merged_ranges and start <= merged_ranges[-1][1]:
|
|
188
|
+
# Overlapping or adjacent - merge
|
|
189
|
+
merged_ranges[-1] = (merged_ranges[-1][0], max(end, merged_ranges[-1][1]))
|
|
190
|
+
else:
|
|
191
|
+
merged_ranges.append((start, end))
|
|
192
|
+
|
|
107
193
|
# Build the escaped content
|
|
108
194
|
result = []
|
|
109
195
|
last_end = 0
|
|
110
196
|
|
|
111
|
-
for start, end in
|
|
197
|
+
for start, end in merged_ranges:
|
|
112
198
|
# Escape everything outside protected ranges
|
|
113
199
|
unprotected_text = content[last_end:start]
|
|
114
200
|
for char, replacement in HTML_ESCAPE_CHARS.items():
|
|
@@ -134,7 +220,7 @@ class ConsoleDisplay:
|
|
|
134
220
|
This centralizes the UI display logic used by LLM implementations.
|
|
135
221
|
"""
|
|
136
222
|
|
|
137
|
-
def __init__(self, config=None) -> None:
|
|
223
|
+
def __init__(self, config: Settings | None = None) -> None:
|
|
138
224
|
"""
|
|
139
225
|
Initialize the console display handler.
|
|
140
226
|
|
|
@@ -145,6 +231,29 @@ class ConsoleDisplay:
|
|
|
145
231
|
self._markup = config.logger.enable_markup if config else True
|
|
146
232
|
self._escape_xml = True
|
|
147
233
|
|
|
234
|
+
def resolve_streaming_preferences(self) -> tuple[bool, str]:
|
|
235
|
+
"""Return whether streaming is enabled plus the active mode."""
|
|
236
|
+
if not self.config:
|
|
237
|
+
return True, "markdown"
|
|
238
|
+
|
|
239
|
+
logger_settings = getattr(self.config, "logger", None)
|
|
240
|
+
if not logger_settings:
|
|
241
|
+
return True, "markdown"
|
|
242
|
+
|
|
243
|
+
streaming_mode = getattr(logger_settings, "streaming", "markdown")
|
|
244
|
+
if streaming_mode not in {"markdown", "plain", "none"}:
|
|
245
|
+
streaming_mode = "markdown"
|
|
246
|
+
|
|
247
|
+
# Legacy compatibility: allow streaming_plain_text override
|
|
248
|
+
if streaming_mode == "markdown" and getattr(logger_settings, "streaming_plain_text", False):
|
|
249
|
+
streaming_mode = "plain"
|
|
250
|
+
|
|
251
|
+
show_chat = bool(getattr(logger_settings, "show_chat", True))
|
|
252
|
+
streaming_display = bool(getattr(logger_settings, "streaming_display", True))
|
|
253
|
+
|
|
254
|
+
enabled = show_chat and streaming_display and streaming_mode != "none"
|
|
255
|
+
return enabled, streaming_mode
|
|
256
|
+
|
|
148
257
|
@staticmethod
|
|
149
258
|
def _format_elapsed(elapsed: float) -> str:
|
|
150
259
|
"""Format elapsed seconds for display."""
|
|
@@ -223,44 +332,12 @@ class ConsoleDisplay:
|
|
|
223
332
|
console.console.print(additional_message, markup=self._markup)
|
|
224
333
|
|
|
225
334
|
# Handle bottom separator with optional metadata
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
display_items = self._shorten_items(bottom_metadata, max_item_length)
|
|
233
|
-
|
|
234
|
-
# Format the metadata with highlighting, clipped to available width
|
|
235
|
-
# Compute available width for the metadata segment (excluding the fixed prefix/suffix)
|
|
236
|
-
total_width = console.console.size.width
|
|
237
|
-
prefix = Text("─| ")
|
|
238
|
-
prefix.stylize("dim")
|
|
239
|
-
suffix = Text(" |")
|
|
240
|
-
suffix.stylize("dim")
|
|
241
|
-
available = max(0, total_width - prefix.cell_len - suffix.cell_len)
|
|
242
|
-
|
|
243
|
-
metadata_text = self._format_bottom_metadata(
|
|
244
|
-
display_items,
|
|
245
|
-
highlight_index,
|
|
246
|
-
config["highlight_color"],
|
|
247
|
-
max_width=available,
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
# Create the separator line with metadata
|
|
251
|
-
line = Text()
|
|
252
|
-
line.append_text(prefix)
|
|
253
|
-
line.append_text(metadata_text)
|
|
254
|
-
line.append_text(suffix)
|
|
255
|
-
remaining = total_width - line.cell_len
|
|
256
|
-
if remaining > 0:
|
|
257
|
-
line.append("─" * remaining, style="dim")
|
|
258
|
-
console.console.print(line, markup=self._markup)
|
|
259
|
-
else:
|
|
260
|
-
# No metadata - continuous bar
|
|
261
|
-
console.console.print("─" * console.console.size.width, style="dim")
|
|
262
|
-
|
|
263
|
-
console.console.print()
|
|
335
|
+
self._render_bottom_metadata(
|
|
336
|
+
message_type=message_type,
|
|
337
|
+
bottom_metadata=bottom_metadata,
|
|
338
|
+
highlight_index=highlight_index,
|
|
339
|
+
max_item_length=max_item_length,
|
|
340
|
+
)
|
|
264
341
|
|
|
265
342
|
def _display_content(
|
|
266
343
|
self,
|
|
@@ -483,6 +560,58 @@ class ConsoleDisplay:
|
|
|
483
560
|
"""
|
|
484
561
|
return [item[: max_length - 1] + "…" if len(item) > max_length else item for item in items]
|
|
485
562
|
|
|
563
|
+
def _render_bottom_metadata(
|
|
564
|
+
self,
|
|
565
|
+
*,
|
|
566
|
+
message_type: MessageType,
|
|
567
|
+
bottom_metadata: List[str] | None,
|
|
568
|
+
highlight_index: int | None,
|
|
569
|
+
max_item_length: int | None,
|
|
570
|
+
) -> None:
|
|
571
|
+
"""
|
|
572
|
+
Render the bottom separator line with optional metadata.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
message_type: The type of message being displayed
|
|
576
|
+
bottom_metadata: Optional list of items to show in the separator
|
|
577
|
+
highlight_index: Optional index of the item to highlight
|
|
578
|
+
max_item_length: Optional maximum length for individual items
|
|
579
|
+
"""
|
|
580
|
+
console.console.print()
|
|
581
|
+
|
|
582
|
+
if bottom_metadata:
|
|
583
|
+
display_items = bottom_metadata
|
|
584
|
+
if max_item_length:
|
|
585
|
+
display_items = self._shorten_items(bottom_metadata, max_item_length)
|
|
586
|
+
|
|
587
|
+
total_width = console.console.size.width
|
|
588
|
+
prefix = Text("─| ")
|
|
589
|
+
prefix.stylize("dim")
|
|
590
|
+
suffix = Text(" |")
|
|
591
|
+
suffix.stylize("dim")
|
|
592
|
+
available = max(0, total_width - prefix.cell_len - suffix.cell_len)
|
|
593
|
+
|
|
594
|
+
highlight_color = MESSAGE_CONFIGS[message_type]["highlight_color"]
|
|
595
|
+
metadata_text = self._format_bottom_metadata(
|
|
596
|
+
display_items,
|
|
597
|
+
highlight_index,
|
|
598
|
+
highlight_color,
|
|
599
|
+
max_width=available,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
line = Text()
|
|
603
|
+
line.append_text(prefix)
|
|
604
|
+
line.append_text(metadata_text)
|
|
605
|
+
line.append_text(suffix)
|
|
606
|
+
remaining = total_width - line.cell_len
|
|
607
|
+
if remaining > 0:
|
|
608
|
+
line.append("─" * remaining, style="dim")
|
|
609
|
+
console.console.print(line, markup=self._markup)
|
|
610
|
+
else:
|
|
611
|
+
console.console.print("─" * console.console.size.width, style="dim")
|
|
612
|
+
|
|
613
|
+
console.console.print()
|
|
614
|
+
|
|
486
615
|
def _format_bottom_metadata(
|
|
487
616
|
self,
|
|
488
617
|
items: List[str],
|
|
@@ -538,8 +667,21 @@ class ConsoleDisplay:
|
|
|
538
667
|
|
|
539
668
|
return formatted
|
|
540
669
|
|
|
541
|
-
def show_tool_result(
|
|
542
|
-
|
|
670
|
+
def show_tool_result(
|
|
671
|
+
self,
|
|
672
|
+
result: CallToolResult,
|
|
673
|
+
name: str | None = None,
|
|
674
|
+
tool_name: str | None = None,
|
|
675
|
+
skybridge_config: "SkybridgeServerConfig | None" = None,
|
|
676
|
+
) -> None:
|
|
677
|
+
"""Display a tool result in the new visual style.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
result: The tool result to display
|
|
681
|
+
name: Optional agent name
|
|
682
|
+
tool_name: Optional tool name for skybridge detection
|
|
683
|
+
skybridge_config: Optional skybridge configuration for the server
|
|
684
|
+
"""
|
|
543
685
|
if not self.config or not self.config.logger.show_tools:
|
|
544
686
|
return
|
|
545
687
|
|
|
@@ -548,6 +690,20 @@ class ConsoleDisplay:
|
|
|
548
690
|
|
|
549
691
|
# Analyze content to determine display format and status
|
|
550
692
|
content = result.content
|
|
693
|
+
structured_content = getattr(result, "structuredContent", None)
|
|
694
|
+
has_structured = structured_content is not None
|
|
695
|
+
|
|
696
|
+
# Determine if this is a skybridge tool
|
|
697
|
+
is_skybridge_tool = False
|
|
698
|
+
skybridge_resource_uri = None
|
|
699
|
+
if has_structured and tool_name and skybridge_config:
|
|
700
|
+
# Check if this tool is a valid skybridge tool
|
|
701
|
+
for tool_cfg in skybridge_config.tools:
|
|
702
|
+
if tool_cfg.tool_name == tool_name and tool_cfg.is_valid:
|
|
703
|
+
is_skybridge_tool = True
|
|
704
|
+
skybridge_resource_uri = tool_cfg.resource_uri
|
|
705
|
+
break
|
|
706
|
+
|
|
551
707
|
if result.isError:
|
|
552
708
|
status = "ERROR"
|
|
553
709
|
else:
|
|
@@ -577,7 +733,7 @@ class ConsoleDisplay:
|
|
|
577
733
|
elif channel == "post-sse":
|
|
578
734
|
transport_info = "HTTP (SSE)"
|
|
579
735
|
elif channel == "get":
|
|
580
|
-
transport_info = "
|
|
736
|
+
transport_info = "Legacy SSE"
|
|
581
737
|
elif channel == "resumption":
|
|
582
738
|
transport_info = "Resumption"
|
|
583
739
|
elif channel == "stdio":
|
|
@@ -591,21 +747,138 @@ class ConsoleDisplay:
|
|
|
591
747
|
if isinstance(elapsed, (int, float)):
|
|
592
748
|
bottom_metadata_items.append(self._format_elapsed(float(elapsed)))
|
|
593
749
|
|
|
750
|
+
# Add structured content indicator if present
|
|
751
|
+
if has_structured:
|
|
752
|
+
bottom_metadata_items.append("Structured ■")
|
|
753
|
+
|
|
594
754
|
bottom_metadata = bottom_metadata_items or None
|
|
595
755
|
|
|
596
756
|
# Build right info (without channel info)
|
|
597
757
|
right_info = f"[dim]tool result - {status}[/dim]"
|
|
598
758
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
content
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
759
|
+
if has_structured:
|
|
760
|
+
# Handle structured content display manually to insert it before bottom separator
|
|
761
|
+
# Display main content without bottom separator
|
|
762
|
+
config = MESSAGE_CONFIGS[MessageType.TOOL_RESULT]
|
|
763
|
+
block_color = "red" if result.isError else config["block_color"]
|
|
764
|
+
arrow = config["arrow"]
|
|
765
|
+
arrow_style = config["arrow_style"]
|
|
766
|
+
left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}]"
|
|
767
|
+
if name:
|
|
768
|
+
left += f" [{block_color if not result.isError else 'red'}]{name}[/{block_color if not result.isError else 'red'}]"
|
|
769
|
+
|
|
770
|
+
# Top separator
|
|
771
|
+
self._create_combined_separator_status(left, right_info)
|
|
772
|
+
|
|
773
|
+
# Main content
|
|
774
|
+
self._display_content(
|
|
775
|
+
content, True, result.isError, MessageType.TOOL_RESULT, check_markdown_markers=False
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# Structured content separator and display
|
|
779
|
+
console.console.print()
|
|
780
|
+
total_width = console.console.size.width
|
|
781
|
+
|
|
782
|
+
if is_skybridge_tool:
|
|
783
|
+
# Skybridge: magenta separator with resource URI
|
|
784
|
+
resource_label = (
|
|
785
|
+
f"skybridge resource: {skybridge_resource_uri}"
|
|
786
|
+
if skybridge_resource_uri
|
|
787
|
+
else "skybridge resource"
|
|
788
|
+
)
|
|
789
|
+
prefix = Text("─| ")
|
|
790
|
+
prefix.stylize("dim")
|
|
791
|
+
resource_text = Text(resource_label, style="magenta")
|
|
792
|
+
suffix = Text(" |")
|
|
793
|
+
suffix.stylize("dim")
|
|
794
|
+
|
|
795
|
+
separator_line = Text()
|
|
796
|
+
separator_line.append_text(prefix)
|
|
797
|
+
separator_line.append_text(resource_text)
|
|
798
|
+
separator_line.append_text(suffix)
|
|
799
|
+
remaining = total_width - separator_line.cell_len
|
|
800
|
+
if remaining > 0:
|
|
801
|
+
separator_line.append("─" * remaining, style="dim")
|
|
802
|
+
console.console.print(separator_line, markup=self._markup)
|
|
803
|
+
console.console.print()
|
|
804
|
+
|
|
805
|
+
# Display with bright syntax highlighting
|
|
806
|
+
import json
|
|
807
|
+
|
|
808
|
+
from rich.syntax import Syntax
|
|
809
|
+
|
|
810
|
+
json_str = json.dumps(structured_content, indent=2)
|
|
811
|
+
syntax_obj = Syntax(json_str, "json", theme=CODE_STYLE, background_color="default")
|
|
812
|
+
console.console.print(syntax_obj, markup=self._markup)
|
|
813
|
+
else:
|
|
814
|
+
# Regular tool: dim separator
|
|
815
|
+
prefix = Text("─| ")
|
|
816
|
+
prefix.stylize("dim")
|
|
817
|
+
label_text = Text("Structured Content", style="dim")
|
|
818
|
+
suffix = Text(" |")
|
|
819
|
+
suffix.stylize("dim")
|
|
820
|
+
|
|
821
|
+
separator_line = Text()
|
|
822
|
+
separator_line.append_text(prefix)
|
|
823
|
+
separator_line.append_text(label_text)
|
|
824
|
+
separator_line.append_text(suffix)
|
|
825
|
+
remaining = total_width - separator_line.cell_len
|
|
826
|
+
if remaining > 0:
|
|
827
|
+
separator_line.append("─" * remaining, style="dim")
|
|
828
|
+
console.console.print(separator_line, markup=self._markup)
|
|
829
|
+
console.console.print()
|
|
830
|
+
|
|
831
|
+
# Display truncated content in dim
|
|
832
|
+
from rich.pretty import Pretty
|
|
833
|
+
|
|
834
|
+
if self.config and self.config.logger.truncate_tools:
|
|
835
|
+
pretty_obj = Pretty(structured_content, max_length=10, max_string=50)
|
|
836
|
+
else:
|
|
837
|
+
pretty_obj = Pretty(structured_content)
|
|
838
|
+
console.console.print(pretty_obj, style="dim", markup=self._markup)
|
|
839
|
+
|
|
840
|
+
# Bottom separator with metadata
|
|
841
|
+
console.console.print()
|
|
842
|
+
if bottom_metadata:
|
|
843
|
+
display_items = (
|
|
844
|
+
self._shorten_items(bottom_metadata, 12) if True else bottom_metadata
|
|
845
|
+
)
|
|
846
|
+
prefix = Text("─| ")
|
|
847
|
+
prefix.stylize("dim")
|
|
848
|
+
suffix = Text(" |")
|
|
849
|
+
suffix.stylize("dim")
|
|
850
|
+
available = max(0, total_width - prefix.cell_len - suffix.cell_len)
|
|
851
|
+
|
|
852
|
+
metadata_text = self._format_bottom_metadata(
|
|
853
|
+
display_items,
|
|
854
|
+
None,
|
|
855
|
+
config["highlight_color"],
|
|
856
|
+
max_width=available,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
line = Text()
|
|
860
|
+
line.append_text(prefix)
|
|
861
|
+
line.append_text(metadata_text)
|
|
862
|
+
line.append_text(suffix)
|
|
863
|
+
remaining = total_width - line.cell_len
|
|
864
|
+
if remaining > 0:
|
|
865
|
+
line.append("─" * remaining, style="dim")
|
|
866
|
+
console.console.print(line, markup=self._markup)
|
|
867
|
+
else:
|
|
868
|
+
console.console.print("─" * total_width, style="dim")
|
|
869
|
+
console.console.print()
|
|
870
|
+
|
|
871
|
+
else:
|
|
872
|
+
# No structured content - use standard display
|
|
873
|
+
self.display_message(
|
|
874
|
+
content=content,
|
|
875
|
+
message_type=MessageType.TOOL_RESULT,
|
|
876
|
+
name=name,
|
|
877
|
+
right_info=right_info,
|
|
878
|
+
bottom_metadata=bottom_metadata,
|
|
879
|
+
is_error=result.isError,
|
|
880
|
+
truncate_content=True,
|
|
881
|
+
)
|
|
609
882
|
|
|
610
883
|
def show_tool_call(
|
|
611
884
|
self,
|
|
@@ -669,9 +942,7 @@ class ConsoleDisplay:
|
|
|
669
942
|
# No active prompt_toolkit session - display with rich as before
|
|
670
943
|
# Combined separator and status line
|
|
671
944
|
if agent_name:
|
|
672
|
-
left =
|
|
673
|
-
f"[magenta]▎[/magenta][dim magenta]▶[/dim magenta] [magenta]{agent_name}[/magenta]"
|
|
674
|
-
)
|
|
945
|
+
left = f"[magenta]▎[/magenta][dim magenta]▶[/dim magenta] [magenta]{agent_name}[/magenta]"
|
|
675
946
|
else:
|
|
676
947
|
left = "[magenta]▎[/magenta][dim magenta]▶[/dim magenta]"
|
|
677
948
|
|
|
@@ -727,6 +998,164 @@ class ConsoleDisplay:
|
|
|
727
998
|
console.console.print(combined, markup=self._markup)
|
|
728
999
|
console.console.print()
|
|
729
1000
|
|
|
1001
|
+
@staticmethod
|
|
1002
|
+
def summarize_skybridge_configs(
|
|
1003
|
+
configs: Mapping[str, "SkybridgeServerConfig"] | None,
|
|
1004
|
+
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
|
1005
|
+
"""Convert raw Skybridge configs into display-friendly summary data."""
|
|
1006
|
+
server_rows: List[Dict[str, Any]] = []
|
|
1007
|
+
warnings: List[str] = []
|
|
1008
|
+
warning_seen: Set[str] = set()
|
|
1009
|
+
|
|
1010
|
+
if not configs:
|
|
1011
|
+
return server_rows, warnings
|
|
1012
|
+
|
|
1013
|
+
def add_warning(message: str) -> None:
|
|
1014
|
+
formatted = message.strip()
|
|
1015
|
+
if not formatted:
|
|
1016
|
+
return
|
|
1017
|
+
if formatted not in warning_seen:
|
|
1018
|
+
warnings.append(formatted)
|
|
1019
|
+
warning_seen.add(formatted)
|
|
1020
|
+
|
|
1021
|
+
for server_name in sorted(configs.keys()):
|
|
1022
|
+
config = configs.get(server_name)
|
|
1023
|
+
if not config:
|
|
1024
|
+
continue
|
|
1025
|
+
resources = list(config.ui_resources or [])
|
|
1026
|
+
has_skybridge_signal = bool(
|
|
1027
|
+
config.enabled or resources or config.tools or config.warnings
|
|
1028
|
+
)
|
|
1029
|
+
if not has_skybridge_signal:
|
|
1030
|
+
continue
|
|
1031
|
+
|
|
1032
|
+
valid_resource_count = sum(1 for resource in resources if resource.is_skybridge)
|
|
1033
|
+
|
|
1034
|
+
server_rows.append(
|
|
1035
|
+
{
|
|
1036
|
+
"server_name": server_name,
|
|
1037
|
+
"config": config,
|
|
1038
|
+
"resources": resources,
|
|
1039
|
+
"valid_resource_count": valid_resource_count,
|
|
1040
|
+
"total_resource_count": len(resources),
|
|
1041
|
+
"active_tools": [
|
|
1042
|
+
{
|
|
1043
|
+
"name": tool.display_name,
|
|
1044
|
+
"template": str(tool.template_uri) if tool.template_uri else None,
|
|
1045
|
+
}
|
|
1046
|
+
for tool in config.tools
|
|
1047
|
+
if tool.is_valid
|
|
1048
|
+
],
|
|
1049
|
+
"enabled": config.enabled,
|
|
1050
|
+
}
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
for warning in config.warnings:
|
|
1054
|
+
message = warning.strip()
|
|
1055
|
+
if not message:
|
|
1056
|
+
continue
|
|
1057
|
+
if not message.startswith(server_name):
|
|
1058
|
+
message = f"{server_name} {message}"
|
|
1059
|
+
add_warning(message)
|
|
1060
|
+
|
|
1061
|
+
return server_rows, warnings
|
|
1062
|
+
|
|
1063
|
+
def show_skybridge_summary(
|
|
1064
|
+
self,
|
|
1065
|
+
agent_name: str,
|
|
1066
|
+
configs: Mapping[str, "SkybridgeServerConfig"] | None,
|
|
1067
|
+
) -> None:
|
|
1068
|
+
"""Display Skybridge availability and warnings."""
|
|
1069
|
+
server_rows, warnings = self.summarize_skybridge_configs(configs)
|
|
1070
|
+
|
|
1071
|
+
if not server_rows and not warnings:
|
|
1072
|
+
return
|
|
1073
|
+
|
|
1074
|
+
heading = "[dim]OpenAI Apps SDK ([/dim][cyan]skybridge[/cyan][dim]) detected:[/dim]"
|
|
1075
|
+
console.console.print()
|
|
1076
|
+
console.console.print(heading, markup=self._markup)
|
|
1077
|
+
|
|
1078
|
+
if not server_rows:
|
|
1079
|
+
console.console.print("[dim] ● none detected[/dim]", markup=self._markup)
|
|
1080
|
+
else:
|
|
1081
|
+
for row in server_rows:
|
|
1082
|
+
server_name = row["server_name"]
|
|
1083
|
+
resource_count = row["valid_resource_count"]
|
|
1084
|
+
total_resource_count = row["total_resource_count"]
|
|
1085
|
+
tool_infos = row["active_tools"]
|
|
1086
|
+
enabled = row["enabled"]
|
|
1087
|
+
|
|
1088
|
+
tool_count = len(tool_infos)
|
|
1089
|
+
tool_word = "tool" if tool_count == 1 else "tools"
|
|
1090
|
+
resource_word = (
|
|
1091
|
+
"skybridge resource" if resource_count == 1 else "skybridge resources"
|
|
1092
|
+
)
|
|
1093
|
+
tool_segment = f"[cyan]{tool_count}[/cyan][dim] {tool_word}[/dim]"
|
|
1094
|
+
resource_segment = f"[cyan]{resource_count}[/cyan][dim] {resource_word}[/dim]"
|
|
1095
|
+
name_style = "cyan" if enabled else "yellow"
|
|
1096
|
+
status_suffix = "" if enabled else "[dim] (issues detected)[/dim]"
|
|
1097
|
+
|
|
1098
|
+
console.console.print(
|
|
1099
|
+
f"[dim] ● [/dim][{name_style}]{server_name}[/{name_style}]{status_suffix}"
|
|
1100
|
+
f"[dim] — [/dim]{tool_segment}[dim], [/dim]{resource_segment}",
|
|
1101
|
+
markup=self._markup,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
for tool_info in tool_infos:
|
|
1105
|
+
template_text = (
|
|
1106
|
+
f"[dim] ({tool_info['template']})[/dim]" if tool_info["template"] else ""
|
|
1107
|
+
)
|
|
1108
|
+
console.console.print(
|
|
1109
|
+
f"[dim] ▶ [/dim][white]{tool_info['name']}[/white]{template_text}",
|
|
1110
|
+
markup=self._markup,
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
if tool_count == 0 and resource_count > 0:
|
|
1114
|
+
console.console.print(
|
|
1115
|
+
"[dim] ▶ tools not linked[/dim]",
|
|
1116
|
+
markup=self._markup,
|
|
1117
|
+
)
|
|
1118
|
+
if not enabled and total_resource_count > resource_count:
|
|
1119
|
+
invalid_count = total_resource_count - resource_count
|
|
1120
|
+
invalid_word = "resource" if invalid_count == 1 else "resources"
|
|
1121
|
+
console.console.print(
|
|
1122
|
+
(
|
|
1123
|
+
"[dim] ▶ "
|
|
1124
|
+
f"[/dim][cyan]{invalid_count}[/cyan][dim] {invalid_word} detected with non-skybridge MIME type[/dim]"
|
|
1125
|
+
),
|
|
1126
|
+
markup=self._markup,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
for warning_entry in warnings:
|
|
1130
|
+
console.console.print(
|
|
1131
|
+
f"[dim red] ▶ [/dim red][red]warning[/red] [dim]{warning_entry}[/dim]",
|
|
1132
|
+
markup=self._markup,
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
def _extract_reasoning_content(self, message: "PromptMessageExtended") -> Text | None:
|
|
1136
|
+
"""Extract reasoning channel content as dim text."""
|
|
1137
|
+
channels = message.channels or {}
|
|
1138
|
+
reasoning_blocks = channels.get(REASONING) or []
|
|
1139
|
+
if not reasoning_blocks:
|
|
1140
|
+
return None
|
|
1141
|
+
|
|
1142
|
+
from fast_agent.mcp.helpers.content_helpers import get_text
|
|
1143
|
+
|
|
1144
|
+
reasoning_segments = []
|
|
1145
|
+
for block in reasoning_blocks:
|
|
1146
|
+
text = get_text(block)
|
|
1147
|
+
if text:
|
|
1148
|
+
reasoning_segments.append(text)
|
|
1149
|
+
|
|
1150
|
+
if not reasoning_segments:
|
|
1151
|
+
return None
|
|
1152
|
+
|
|
1153
|
+
joined = "\n".join(reasoning_segments)
|
|
1154
|
+
if not joined.strip():
|
|
1155
|
+
return None
|
|
1156
|
+
|
|
1157
|
+
return Text(joined, style="dim default")
|
|
1158
|
+
|
|
730
1159
|
async def show_assistant_message(
|
|
731
1160
|
self,
|
|
732
1161
|
message_text: Union[str, Text, "PromptMessageExtended"],
|
|
@@ -759,22 +1188,7 @@ class ConsoleDisplay:
|
|
|
759
1188
|
|
|
760
1189
|
if isinstance(message_text, PromptMessageExtended):
|
|
761
1190
|
display_text = message_text.last_text() or ""
|
|
762
|
-
|
|
763
|
-
channels = message_text.channels or {}
|
|
764
|
-
reasoning_blocks = channels.get(REASONING) or []
|
|
765
|
-
if reasoning_blocks:
|
|
766
|
-
from fast_agent.mcp.helpers.content_helpers import get_text
|
|
767
|
-
|
|
768
|
-
reasoning_segments = []
|
|
769
|
-
for block in reasoning_blocks:
|
|
770
|
-
text = get_text(block)
|
|
771
|
-
if text:
|
|
772
|
-
reasoning_segments.append(text)
|
|
773
|
-
|
|
774
|
-
if reasoning_segments:
|
|
775
|
-
joined = "\n".join(reasoning_segments)
|
|
776
|
-
if joined.strip():
|
|
777
|
-
pre_content = Text(joined, style="dim default")
|
|
1191
|
+
pre_content = self._extract_reasoning_content(message_text)
|
|
778
1192
|
else:
|
|
779
1193
|
display_text = message_text
|
|
780
1194
|
|
|
@@ -806,6 +1220,54 @@ class ConsoleDisplay:
|
|
|
806
1220
|
if diagrams:
|
|
807
1221
|
self._display_mermaid_diagrams(diagrams)
|
|
808
1222
|
|
|
1223
|
+
@contextmanager
|
|
1224
|
+
def streaming_assistant_message(
|
|
1225
|
+
self,
|
|
1226
|
+
*,
|
|
1227
|
+
bottom_items: List[str] | None = None,
|
|
1228
|
+
highlight_index: int | None = None,
|
|
1229
|
+
max_item_length: int | None = None,
|
|
1230
|
+
name: str | None = None,
|
|
1231
|
+
model: str | None = None,
|
|
1232
|
+
) -> Iterator["_StreamingMessageHandle"]:
|
|
1233
|
+
"""Create a streaming context for assistant messages."""
|
|
1234
|
+
streaming_enabled, streaming_mode = self.resolve_streaming_preferences()
|
|
1235
|
+
|
|
1236
|
+
if not streaming_enabled:
|
|
1237
|
+
yield _NullStreamingHandle()
|
|
1238
|
+
return
|
|
1239
|
+
|
|
1240
|
+
from fast_agent.ui.progress_display import progress_display
|
|
1241
|
+
|
|
1242
|
+
config = MESSAGE_CONFIGS[MessageType.ASSISTANT]
|
|
1243
|
+
block_color = config["block_color"]
|
|
1244
|
+
arrow = config["arrow"]
|
|
1245
|
+
arrow_style = config["arrow_style"]
|
|
1246
|
+
|
|
1247
|
+
left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}] "
|
|
1248
|
+
if name:
|
|
1249
|
+
left += f"[{block_color}]{name}[/{block_color}]"
|
|
1250
|
+
|
|
1251
|
+
right_info = f"[dim]{model}[/dim]" if model else ""
|
|
1252
|
+
|
|
1253
|
+
# Determine renderer based on streaming mode
|
|
1254
|
+
use_plain_text = streaming_mode == "plain"
|
|
1255
|
+
|
|
1256
|
+
handle = _StreamingMessageHandle(
|
|
1257
|
+
display=self,
|
|
1258
|
+
bottom_items=bottom_items,
|
|
1259
|
+
highlight_index=highlight_index,
|
|
1260
|
+
max_item_length=max_item_length,
|
|
1261
|
+
use_plain_text=use_plain_text,
|
|
1262
|
+
header_left=left,
|
|
1263
|
+
header_right=right_info,
|
|
1264
|
+
progress_display=progress_display,
|
|
1265
|
+
)
|
|
1266
|
+
try:
|
|
1267
|
+
yield handle
|
|
1268
|
+
finally:
|
|
1269
|
+
handle.close()
|
|
1270
|
+
|
|
809
1271
|
def _display_mermaid_diagrams(self, diagrams: List[MermaidDiagram]) -> None:
|
|
810
1272
|
"""Display mermaid diagram links."""
|
|
811
1273
|
diagram_content = Text()
|
|
@@ -1096,3 +1558,403 @@ class ConsoleDisplay:
|
|
|
1096
1558
|
summary_text = " • ".join(summary_parts)
|
|
1097
1559
|
console.console.print(f"[dim]{summary_text}[/dim]")
|
|
1098
1560
|
console.console.print()
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
class _NullStreamingHandle:
|
|
1564
|
+
"""No-op streaming handle used when streaming is disabled."""
|
|
1565
|
+
|
|
1566
|
+
def update(self, _chunk: str) -> None:
|
|
1567
|
+
return
|
|
1568
|
+
|
|
1569
|
+
def finalize(self, _message: "PromptMessageExtended | str") -> None:
|
|
1570
|
+
return
|
|
1571
|
+
|
|
1572
|
+
def close(self) -> None:
|
|
1573
|
+
return
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
class _StreamingMessageHandle:
|
|
1577
|
+
"""Helper that manages live rendering for streaming assistant responses."""
|
|
1578
|
+
|
|
1579
|
+
def __init__(
|
|
1580
|
+
self,
|
|
1581
|
+
*,
|
|
1582
|
+
display: ConsoleDisplay,
|
|
1583
|
+
bottom_items: List[str] | None,
|
|
1584
|
+
highlight_index: int | None,
|
|
1585
|
+
max_item_length: int | None,
|
|
1586
|
+
use_plain_text: bool = False,
|
|
1587
|
+
header_left: str = "",
|
|
1588
|
+
header_right: str = "",
|
|
1589
|
+
progress_display=None,
|
|
1590
|
+
) -> None:
|
|
1591
|
+
self._display = display
|
|
1592
|
+
self._bottom_items = bottom_items
|
|
1593
|
+
self._highlight_index = highlight_index
|
|
1594
|
+
self._max_item_length = max_item_length
|
|
1595
|
+
self._use_plain_text = use_plain_text
|
|
1596
|
+
self._header_left = header_left
|
|
1597
|
+
self._header_right = header_right
|
|
1598
|
+
self._progress_display = progress_display
|
|
1599
|
+
self._progress_paused = False
|
|
1600
|
+
self._buffer: List[str] = []
|
|
1601
|
+
initial_renderable = Text("") if self._use_plain_text else Markdown("")
|
|
1602
|
+
refresh_rate = (
|
|
1603
|
+
PLAIN_STREAM_REFRESH_PER_SECOND
|
|
1604
|
+
if self._use_plain_text
|
|
1605
|
+
else MARKDOWN_STREAM_REFRESH_PER_SECOND
|
|
1606
|
+
)
|
|
1607
|
+
self._live: Live | None = Live(
|
|
1608
|
+
initial_renderable,
|
|
1609
|
+
console=console.console,
|
|
1610
|
+
vertical_overflow="ellipsis",
|
|
1611
|
+
refresh_per_second=refresh_rate,
|
|
1612
|
+
transient=True,
|
|
1613
|
+
)
|
|
1614
|
+
self._live_started = False
|
|
1615
|
+
self._active = True
|
|
1616
|
+
self._finalized = False
|
|
1617
|
+
# Track whether we're in a table to batch updates
|
|
1618
|
+
self._in_table = False
|
|
1619
|
+
self._pending_table_row = ""
|
|
1620
|
+
# Smart markdown truncator for creating display window (doesn't mutate buffer)
|
|
1621
|
+
self._truncator = MarkdownTruncator(target_height_ratio=MARKDOWN_STREAM_TARGET_RATIO)
|
|
1622
|
+
self._plain_truncator = (
|
|
1623
|
+
PlainTextTruncator(target_height_ratio=PLAIN_STREAM_TARGET_RATIO)
|
|
1624
|
+
if self._use_plain_text
|
|
1625
|
+
else None
|
|
1626
|
+
)
|
|
1627
|
+
self._max_render_height = 0
|
|
1628
|
+
|
|
1629
|
+
def update(self, chunk: str) -> None:
|
|
1630
|
+
if not self._active or not chunk:
|
|
1631
|
+
return
|
|
1632
|
+
|
|
1633
|
+
self._ensure_started()
|
|
1634
|
+
|
|
1635
|
+
if self._use_plain_text:
|
|
1636
|
+
chunk = self._wrap_plain_chunk(chunk)
|
|
1637
|
+
if self._pending_table_row:
|
|
1638
|
+
self._buffer.append(self._pending_table_row)
|
|
1639
|
+
self._pending_table_row = ""
|
|
1640
|
+
else:
|
|
1641
|
+
# Detect if we're streaming table content
|
|
1642
|
+
# Tables have rows starting with '|' and we want to batch updates until we get a complete row
|
|
1643
|
+
text_so_far = "".join(self._buffer)
|
|
1644
|
+
|
|
1645
|
+
# Check if we're currently in a table (last non-empty line starts with |)
|
|
1646
|
+
lines = text_so_far.strip().split("\n")
|
|
1647
|
+
last_line = lines[-1] if lines else ""
|
|
1648
|
+
currently_in_table = last_line.strip().startswith("|")
|
|
1649
|
+
|
|
1650
|
+
# If we're in a table and the chunk doesn't contain a newline, accumulate it
|
|
1651
|
+
if currently_in_table and "\n" not in chunk:
|
|
1652
|
+
self._pending_table_row += chunk
|
|
1653
|
+
# Don't update display yet - wait for complete row
|
|
1654
|
+
return
|
|
1655
|
+
|
|
1656
|
+
# If we have a pending table row, flush it now
|
|
1657
|
+
if self._pending_table_row:
|
|
1658
|
+
self._buffer.append(self._pending_table_row)
|
|
1659
|
+
self._pending_table_row = ""
|
|
1660
|
+
|
|
1661
|
+
self._buffer.append(chunk)
|
|
1662
|
+
|
|
1663
|
+
text = "".join(self._buffer)
|
|
1664
|
+
|
|
1665
|
+
if self._use_plain_text:
|
|
1666
|
+
trimmed = self._trim_to_displayable(text)
|
|
1667
|
+
if trimmed != text:
|
|
1668
|
+
text = trimmed
|
|
1669
|
+
self._buffer = [trimmed]
|
|
1670
|
+
|
|
1671
|
+
# Guard against single logical paragraphs that would expand far wider than expected.
|
|
1672
|
+
trailing_paragraph = self._extract_trailing_paragraph(text)
|
|
1673
|
+
if trailing_paragraph and "\n" not in trailing_paragraph:
|
|
1674
|
+
width = max(1, console.console.size.width)
|
|
1675
|
+
target_ratio = (
|
|
1676
|
+
PLAIN_STREAM_TARGET_RATIO if self._use_plain_text else MARKDOWN_STREAM_TARGET_RATIO
|
|
1677
|
+
)
|
|
1678
|
+
target_rows = max(
|
|
1679
|
+
1,
|
|
1680
|
+
int(console.console.size.height * target_ratio) - 1,
|
|
1681
|
+
)
|
|
1682
|
+
estimated_rows = math.ceil(len(trailing_paragraph.expandtabs()) / width)
|
|
1683
|
+
if estimated_rows > target_rows:
|
|
1684
|
+
trimmed_text = self._trim_to_displayable(text)
|
|
1685
|
+
if trimmed_text != text:
|
|
1686
|
+
text = trimmed_text
|
|
1687
|
+
self._buffer = [trimmed_text]
|
|
1688
|
+
|
|
1689
|
+
# Trim buffer periodically to avoid unbounded growth
|
|
1690
|
+
# Keep only what can fit in ~1.5x terminal height
|
|
1691
|
+
if len(self._buffer) > 10:
|
|
1692
|
+
text = self._trim_to_displayable(text)
|
|
1693
|
+
self._buffer = [text]
|
|
1694
|
+
|
|
1695
|
+
if self._live:
|
|
1696
|
+
# Build the header bar
|
|
1697
|
+
header = self._build_header()
|
|
1698
|
+
|
|
1699
|
+
# Build the content renderable
|
|
1700
|
+
max_allowed_height = max(1, console.console.size.height - 2)
|
|
1701
|
+
self._max_render_height = min(self._max_render_height, max_allowed_height)
|
|
1702
|
+
|
|
1703
|
+
if self._use_plain_text:
|
|
1704
|
+
# Plain text rendering - no markdown processing
|
|
1705
|
+
content_height = self._estimate_plain_render_height(text)
|
|
1706
|
+
budget_height = min(content_height + PLAIN_STREAM_HEIGHT_FUDGE, max_allowed_height)
|
|
1707
|
+
|
|
1708
|
+
if budget_height > self._max_render_height:
|
|
1709
|
+
self._max_render_height = budget_height
|
|
1710
|
+
|
|
1711
|
+
padding_lines = max(0, self._max_render_height - content_height)
|
|
1712
|
+
display_text = text + ("\n" * padding_lines if padding_lines else "")
|
|
1713
|
+
content = Text(display_text)
|
|
1714
|
+
else:
|
|
1715
|
+
# Markdown rendering with XML escaping
|
|
1716
|
+
prepared = _prepare_markdown_content(text, self._display._escape_xml)
|
|
1717
|
+
prepared_for_display = self._close_incomplete_code_blocks(prepared)
|
|
1718
|
+
|
|
1719
|
+
content_height = self._truncator.measure_rendered_height(
|
|
1720
|
+
prepared_for_display, console.console, CODE_STYLE
|
|
1721
|
+
)
|
|
1722
|
+
budget_height = min(
|
|
1723
|
+
content_height + MARKDOWN_STREAM_HEIGHT_FUDGE, max_allowed_height
|
|
1724
|
+
)
|
|
1725
|
+
|
|
1726
|
+
if budget_height > self._max_render_height:
|
|
1727
|
+
self._max_render_height = budget_height
|
|
1728
|
+
|
|
1729
|
+
padding_lines = max(0, self._max_render_height - content_height)
|
|
1730
|
+
if padding_lines:
|
|
1731
|
+
prepared_for_display = prepared_for_display + ("\n" * padding_lines)
|
|
1732
|
+
|
|
1733
|
+
content = Markdown(prepared_for_display, code_theme=CODE_STYLE)
|
|
1734
|
+
|
|
1735
|
+
# Combine header and content using Group
|
|
1736
|
+
from rich.console import Group
|
|
1737
|
+
|
|
1738
|
+
header_with_spacing = header.copy()
|
|
1739
|
+
header_with_spacing.append("\n", style="default")
|
|
1740
|
+
|
|
1741
|
+
combined = Group(header_with_spacing, content)
|
|
1742
|
+
self._live.update(combined)
|
|
1743
|
+
|
|
1744
|
+
def _build_header(self) -> Text:
|
|
1745
|
+
"""Build the header bar as a Text renderable.
|
|
1746
|
+
|
|
1747
|
+
Returns:
|
|
1748
|
+
Text object representing the header bar.
|
|
1749
|
+
"""
|
|
1750
|
+
width = console.console.size.width
|
|
1751
|
+
|
|
1752
|
+
# Create left text
|
|
1753
|
+
left_text = Text.from_markup(self._header_left)
|
|
1754
|
+
|
|
1755
|
+
# Create right text if we have info
|
|
1756
|
+
if self._header_right and self._header_right.strip():
|
|
1757
|
+
# Add dim brackets around the right info
|
|
1758
|
+
right_text = Text()
|
|
1759
|
+
right_text.append("[", style="dim")
|
|
1760
|
+
right_text.append_text(Text.from_markup(self._header_right))
|
|
1761
|
+
right_text.append("]", style="dim")
|
|
1762
|
+
# Calculate separator count
|
|
1763
|
+
separator_count = width - left_text.cell_len - right_text.cell_len
|
|
1764
|
+
if separator_count < 1:
|
|
1765
|
+
separator_count = 1 # Always at least 1 separator
|
|
1766
|
+
else:
|
|
1767
|
+
right_text = Text("")
|
|
1768
|
+
separator_count = width - left_text.cell_len
|
|
1769
|
+
|
|
1770
|
+
# Build the combined line
|
|
1771
|
+
combined = Text()
|
|
1772
|
+
combined.append_text(left_text)
|
|
1773
|
+
combined.append(" ", style="default")
|
|
1774
|
+
combined.append("─" * (separator_count - 1), style="dim")
|
|
1775
|
+
combined.append_text(right_text)
|
|
1776
|
+
|
|
1777
|
+
return combined
|
|
1778
|
+
|
|
1779
|
+
def _ensure_started(self) -> None:
|
|
1780
|
+
"""Start live rendering and pause progress display if needed."""
|
|
1781
|
+
if self._live_started:
|
|
1782
|
+
return
|
|
1783
|
+
|
|
1784
|
+
if self._progress_display and not self._progress_paused:
|
|
1785
|
+
try:
|
|
1786
|
+
self._progress_display.pause()
|
|
1787
|
+
self._progress_paused = True
|
|
1788
|
+
except Exception:
|
|
1789
|
+
self._progress_paused = False
|
|
1790
|
+
|
|
1791
|
+
if self._live and not self._live_started:
|
|
1792
|
+
self._live.__enter__()
|
|
1793
|
+
self._live_started = True
|
|
1794
|
+
|
|
1795
|
+
def _close_incomplete_code_blocks(self, text: str) -> str:
|
|
1796
|
+
"""Add temporary closing fence to incomplete code blocks for display.
|
|
1797
|
+
|
|
1798
|
+
During streaming, incomplete code blocks (opening fence without closing)
|
|
1799
|
+
are rendered as literal text by Rich's Markdown renderer. This method
|
|
1800
|
+
adds a temporary closing fence so the code can be syntax-highlighted
|
|
1801
|
+
during streaming display.
|
|
1802
|
+
|
|
1803
|
+
When the real closing fence arrives in a subsequent chunk, this method
|
|
1804
|
+
will detect the now-complete block and stop adding the temporary fence.
|
|
1805
|
+
|
|
1806
|
+
Args:
|
|
1807
|
+
text: The markdown text that may contain incomplete code blocks.
|
|
1808
|
+
|
|
1809
|
+
Returns:
|
|
1810
|
+
Text with temporary closing fences added for incomplete code blocks.
|
|
1811
|
+
"""
|
|
1812
|
+
import re
|
|
1813
|
+
|
|
1814
|
+
# Count opening and closing fences
|
|
1815
|
+
opening_fences = len(re.findall(r"^```", text, re.MULTILINE))
|
|
1816
|
+
closing_fences = len(re.findall(r"^```\s*$", text, re.MULTILINE))
|
|
1817
|
+
|
|
1818
|
+
# If we have more opening fences than closing fences, and the text
|
|
1819
|
+
# doesn't end with a closing fence, we have an incomplete code block
|
|
1820
|
+
if opening_fences > closing_fences:
|
|
1821
|
+
# Check if text ends with a closing fence (might be partial line)
|
|
1822
|
+
if not re.search(r"```\s*$", text):
|
|
1823
|
+
# Add temporary closing fence for display only
|
|
1824
|
+
return text + "\n```\n"
|
|
1825
|
+
|
|
1826
|
+
return text
|
|
1827
|
+
|
|
1828
|
+
def _trim_to_displayable(self, text: str) -> str:
|
|
1829
|
+
"""Trim text to keep only displayable content plus small buffer.
|
|
1830
|
+
|
|
1831
|
+
Keeps ~1.5x terminal height worth of recent content.
|
|
1832
|
+
Uses the optimized streaming truncator for better performance.
|
|
1833
|
+
|
|
1834
|
+
Args:
|
|
1835
|
+
text: Full text to trim
|
|
1836
|
+
|
|
1837
|
+
Returns:
|
|
1838
|
+
Trimmed text (most recent content)
|
|
1839
|
+
"""
|
|
1840
|
+
if not text:
|
|
1841
|
+
return text
|
|
1842
|
+
|
|
1843
|
+
terminal_height = console.console.size.height - 1
|
|
1844
|
+
|
|
1845
|
+
if self._use_plain_text and self._plain_truncator:
|
|
1846
|
+
terminal_width = console.console.size.width
|
|
1847
|
+
return self._plain_truncator.truncate(
|
|
1848
|
+
text,
|
|
1849
|
+
terminal_height=terminal_height,
|
|
1850
|
+
terminal_width=terminal_width,
|
|
1851
|
+
)
|
|
1852
|
+
|
|
1853
|
+
# Use the optimized streaming truncator (16x faster!) for markdown
|
|
1854
|
+
return self._truncator.truncate(
|
|
1855
|
+
text,
|
|
1856
|
+
terminal_height=terminal_height,
|
|
1857
|
+
console=console.console,
|
|
1858
|
+
code_theme=CODE_STYLE,
|
|
1859
|
+
prefer_recent=True, # Streaming mode
|
|
1860
|
+
)
|
|
1861
|
+
|
|
1862
|
+
def finalize(self, _message: "PromptMessageExtended | str") -> None:
|
|
1863
|
+
if not self._active or self._finalized:
|
|
1864
|
+
return
|
|
1865
|
+
|
|
1866
|
+
self._finalized = True
|
|
1867
|
+
self.close()
|
|
1868
|
+
|
|
1869
|
+
def close(self) -> None:
|
|
1870
|
+
if self._live and self._live_started:
|
|
1871
|
+
self._live.__exit__(None, None, None)
|
|
1872
|
+
self._live = None
|
|
1873
|
+
self._live_started = False
|
|
1874
|
+
if self._progress_display and self._progress_paused:
|
|
1875
|
+
try:
|
|
1876
|
+
self._progress_display.resume()
|
|
1877
|
+
except Exception:
|
|
1878
|
+
pass
|
|
1879
|
+
finally:
|
|
1880
|
+
self._progress_paused = False
|
|
1881
|
+
self._active = False
|
|
1882
|
+
self._max_render_height = 0
|
|
1883
|
+
|
|
1884
|
+
def _extract_trailing_paragraph(self, text: str) -> str:
|
|
1885
|
+
"""Return text since the last blank line, used to detect in-progress paragraphs."""
|
|
1886
|
+
if not text:
|
|
1887
|
+
return ""
|
|
1888
|
+
double_break = text.rfind("\n\n")
|
|
1889
|
+
if double_break != -1:
|
|
1890
|
+
candidate = text[double_break + 2 :]
|
|
1891
|
+
else:
|
|
1892
|
+
candidate = text
|
|
1893
|
+
if "\n" in candidate:
|
|
1894
|
+
candidate = candidate.split("\n")[-1]
|
|
1895
|
+
return candidate
|
|
1896
|
+
|
|
1897
|
+
def _wrap_plain_chunk(self, chunk: str) -> str:
|
|
1898
|
+
"""Insert soft line breaks into long plain text segments."""
|
|
1899
|
+
width = max(1, console.console.size.width)
|
|
1900
|
+
if not chunk or width <= 1:
|
|
1901
|
+
return chunk
|
|
1902
|
+
|
|
1903
|
+
result_segments: List[str] = []
|
|
1904
|
+
start = 0
|
|
1905
|
+
length = len(chunk)
|
|
1906
|
+
|
|
1907
|
+
while start < length:
|
|
1908
|
+
newline_pos = chunk.find("\n", start)
|
|
1909
|
+
if newline_pos == -1:
|
|
1910
|
+
line = chunk[start:]
|
|
1911
|
+
delimiter = ""
|
|
1912
|
+
start = length
|
|
1913
|
+
else:
|
|
1914
|
+
line = chunk[start:newline_pos]
|
|
1915
|
+
delimiter = "\n"
|
|
1916
|
+
start = newline_pos + 1
|
|
1917
|
+
|
|
1918
|
+
if len(line.expandtabs()) > width:
|
|
1919
|
+
wrapped = self._wrap_plain_line(line, width)
|
|
1920
|
+
result_segments.append("\n".join(wrapped))
|
|
1921
|
+
else:
|
|
1922
|
+
result_segments.append(line)
|
|
1923
|
+
|
|
1924
|
+
result_segments.append(delimiter)
|
|
1925
|
+
|
|
1926
|
+
return "".join(result_segments)
|
|
1927
|
+
|
|
1928
|
+
@staticmethod
|
|
1929
|
+
def _wrap_plain_line(line: str, width: int) -> List[str]:
|
|
1930
|
+
"""Wrap a single line to the terminal width."""
|
|
1931
|
+
if not line:
|
|
1932
|
+
return [""]
|
|
1933
|
+
|
|
1934
|
+
segments: List[str] = []
|
|
1935
|
+
remaining = line
|
|
1936
|
+
|
|
1937
|
+
while len(remaining) > width:
|
|
1938
|
+
break_at = remaining.rfind(" ", 0, width)
|
|
1939
|
+
if break_at == -1 or break_at < width // 2:
|
|
1940
|
+
break_at = width
|
|
1941
|
+
segments.append(remaining[:break_at])
|
|
1942
|
+
remaining = remaining[break_at:]
|
|
1943
|
+
else:
|
|
1944
|
+
segments.append(remaining[:break_at])
|
|
1945
|
+
remaining = remaining[break_at + 1 :]
|
|
1946
|
+
segments.append(remaining)
|
|
1947
|
+
return segments
|
|
1948
|
+
|
|
1949
|
+
def _estimate_plain_render_height(self, text: str) -> int:
|
|
1950
|
+
"""Estimate rendered height for plain text taking terminal width into account."""
|
|
1951
|
+
if not text:
|
|
1952
|
+
return 0
|
|
1953
|
+
|
|
1954
|
+
width = max(1, console.console.size.width)
|
|
1955
|
+
lines = text.split("\n")
|
|
1956
|
+
total = 0
|
|
1957
|
+
for line in lines:
|
|
1958
|
+
expanded_len = len(line.expandtabs())
|
|
1959
|
+
total += max(1, math.ceil(expanded_len / width)) if expanded_len else 1
|
|
1960
|
+
return total
|