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.

Files changed (44) hide show
  1. fast_agent/agents/llm_agent.py +59 -37
  2. fast_agent/agents/llm_decorator.py +13 -2
  3. fast_agent/agents/mcp_agent.py +21 -5
  4. fast_agent/agents/tool_agent.py +41 -29
  5. fast_agent/agents/workflow/router_agent.py +2 -1
  6. fast_agent/cli/commands/check_config.py +48 -1
  7. fast_agent/config.py +65 -2
  8. fast_agent/constants.py +3 -0
  9. fast_agent/context.py +42 -9
  10. fast_agent/core/fastagent.py +14 -1
  11. fast_agent/core/logging/listeners.py +1 -1
  12. fast_agent/core/validation.py +31 -33
  13. fast_agent/event_progress.py +2 -3
  14. fast_agent/human_input/form_fields.py +4 -1
  15. fast_agent/interfaces.py +12 -2
  16. fast_agent/llm/fastagent_llm.py +31 -0
  17. fast_agent/llm/model_database.py +2 -2
  18. fast_agent/llm/model_factory.py +8 -1
  19. fast_agent/llm/provider_key_manager.py +1 -0
  20. fast_agent/llm/provider_types.py +1 -0
  21. fast_agent/llm/request_params.py +3 -1
  22. fast_agent/mcp/mcp_aggregator.py +313 -40
  23. fast_agent/mcp/mcp_connection_manager.py +39 -9
  24. fast_agent/mcp/prompt_message_extended.py +2 -2
  25. fast_agent/mcp/skybridge.py +45 -0
  26. fast_agent/mcp/sse_tracking.py +287 -0
  27. fast_agent/mcp/transport_tracking.py +37 -3
  28. fast_agent/mcp/types.py +24 -0
  29. fast_agent/resources/examples/workflows/router.py +1 -0
  30. fast_agent/resources/setup/fastagent.config.yaml +7 -1
  31. fast_agent/ui/console_display.py +946 -84
  32. fast_agent/ui/elicitation_form.py +23 -1
  33. fast_agent/ui/enhanced_prompt.py +153 -58
  34. fast_agent/ui/interactive_prompt.py +57 -34
  35. fast_agent/ui/markdown_truncator.py +942 -0
  36. fast_agent/ui/mcp_display.py +110 -29
  37. fast_agent/ui/plain_text_truncator.py +68 -0
  38. fast_agent/ui/rich_progress.py +4 -1
  39. fast_agent/ui/streaming_buffer.py +449 -0
  40. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/METADATA +4 -3
  41. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/RECORD +44 -38
  42. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/WHEEL +0 -0
  43. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/entry_points.txt +0 -0
  44. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,942 @@
1
+ """Smart markdown truncation that preserves markdown context.
2
+
3
+ This module provides intelligent truncation of markdown text for streaming displays,
4
+ ensuring that markdown structures (code blocks, lists, blockquotes) are preserved
5
+ when possible, and gracefully degrading when single blocks are too large.
6
+
7
+ KEY CONCEPT: Truncation Strategy
8
+ =================================
9
+
10
+ In STREAMING MODE (prefer_recent=True):
11
+ - Always show MOST RECENT content (keep end, remove beginning)
12
+ - Why: Users are following along as content streams in. They want to see the
13
+ current position, not what was written at the start.
14
+ - For TABLES: Show the most recent rows while preserving the header
15
+ - Example: Table with 100 rows - show header + last 20 rows (not first 20)
16
+
17
+ In STATIC MODE (prefer_recent=False):
18
+ - For TABLE-DOMINANT content (>50% table lines): Show FIRST page
19
+ - For TEXT content: Show MOST RECENT
20
+ - Example: Tool output listing 100 files - show header + first 20 rows
21
+
22
+ Context Preservation
23
+ ====================
24
+
25
+ When truncating removes the opening of a structure, we restore it:
26
+ - CODE BLOCKS: Prepend ```language fence (only if it was removed)
27
+ - TABLES: Prepend header row + separator row (only if they were removed)
28
+
29
+ This ensures truncated content still renders correctly as markdown.
30
+ """
31
+
32
+ from dataclasses import dataclass
33
+ from typing import Iterable, List, Optional
34
+
35
+ from markdown_it import MarkdownIt
36
+ from markdown_it.token import Token
37
+ from rich.console import Console
38
+ from rich.markdown import Markdown
39
+ from rich.segment import Segment
40
+
41
+
42
+ @dataclass
43
+ class TruncationPoint:
44
+ """Represents a position in text where truncation is safe."""
45
+
46
+ char_position: int
47
+ block_type: str
48
+ token: Token
49
+ is_closing: bool
50
+
51
+
52
+ @dataclass
53
+ class CodeBlockInfo:
54
+ """Information about a code block in the document."""
55
+
56
+ start_pos: int
57
+ end_pos: int
58
+ fence_line: int
59
+ language: str
60
+ token: Token
61
+
62
+
63
+ @dataclass
64
+ class TableInfo:
65
+ """Information about a table in the document."""
66
+
67
+ start_pos: int
68
+ end_pos: int
69
+ thead_start_pos: int
70
+ thead_end_pos: int
71
+ tbody_start_pos: int
72
+ tbody_end_pos: int
73
+ header_lines: List[str] # Header + separator rows
74
+
75
+
76
+ class MarkdownTruncator:
77
+ """Handles intelligent truncation of markdown text while preserving context."""
78
+
79
+ def __init__(self, target_height_ratio: float = 0.8):
80
+ """Initialize the truncator.
81
+
82
+ Args:
83
+ target_height_ratio: Target height as ratio of terminal height (0.0-1.0).
84
+ After truncation, aim to keep content at this ratio of terminal height.
85
+ """
86
+ self.target_height_ratio = target_height_ratio
87
+ self.parser = MarkdownIt().enable("strikethrough").enable("table")
88
+ # Cache for streaming mode to avoid redundant work
89
+ self._last_full_text: str | None = None
90
+ self._last_truncated_text: str | None = None
91
+ self._last_terminal_height: int | None = None
92
+
93
+ def truncate(
94
+ self,
95
+ text: str,
96
+ terminal_height: int,
97
+ console: Console,
98
+ code_theme: str = "monokai",
99
+ prefer_recent: bool = False,
100
+ ) -> str:
101
+ """Truncate markdown text to fit within terminal height.
102
+
103
+ This method attempts to truncate at safe block boundaries (between paragraphs,
104
+ after code blocks, etc.). If no safe boundary works (e.g., single block is
105
+ too large), it falls back to character-based truncation.
106
+
107
+ Args:
108
+ text: The markdown text to truncate.
109
+ terminal_height: Height of the terminal in lines.
110
+ console: Rich Console instance for measuring rendered height.
111
+ code_theme: Theme for code syntax highlighting.
112
+ prefer_recent: If True, always show most recent content (streaming mode).
113
+ This overrides table-dominant detection to ensure streaming tables
114
+ show the latest rows, not the first rows.
115
+
116
+ Returns:
117
+ Truncated markdown text that fits within target height.
118
+ """
119
+ if not text:
120
+ return text
121
+
122
+ # Fast path for streaming: use incremental truncation
123
+ if prefer_recent:
124
+ return self._truncate_streaming(text, terminal_height, console, code_theme)
125
+
126
+ # Measure current height
127
+ current_height = self._measure_rendered_height(text, console, code_theme)
128
+
129
+ if current_height <= terminal_height:
130
+ # No truncation needed
131
+ return text
132
+
133
+ target_height = int(terminal_height * self.target_height_ratio)
134
+
135
+ # Find safe truncation points (block boundaries)
136
+ safe_points = self._find_safe_truncation_points(text)
137
+
138
+ if not safe_points:
139
+ # No safe points found, fall back to character truncation
140
+ truncated = self._truncate_by_characters(text, target_height, console, code_theme)
141
+ # Ensure code fence is preserved if we truncated within a code block
142
+ truncated = self._ensure_code_fence_if_needed(text, truncated)
143
+ # Ensure table header is preserved if we truncated within a table body
144
+ return self._ensure_table_header_if_needed(text, truncated)
145
+
146
+ # Determine truncation strategy BEFORE finding best point
147
+ # This is needed because _find_best_truncation_point needs to know
148
+ # which direction to test (keep beginning vs keep end)
149
+ is_table_content = False if prefer_recent else self._is_primary_content_table(text)
150
+
151
+ # Try to find the best truncation point
152
+ best_point = self._find_best_truncation_point(
153
+ text, safe_points, target_height, console, code_theme, keep_beginning=is_table_content
154
+ )
155
+
156
+ if best_point is None:
157
+ # No safe point works, fall back to character truncation
158
+ truncated = self._truncate_by_characters(text, target_height, console, code_theme)
159
+ # Ensure code fence is preserved if we truncated within a code block
160
+ truncated = self._ensure_code_fence_if_needed(text, truncated)
161
+ # Ensure table header is preserved if we truncated within a table body
162
+ return self._ensure_table_header_if_needed(text, truncated)
163
+
164
+ # ============================================================================
165
+ # TRUNCATION STRATEGY: Two Different Behaviors
166
+ # ============================================================================
167
+ #
168
+ # We use different truncation strategies depending on content type:
169
+ #
170
+ # 1. TABLES: Show FIRST page (keep beginning, remove end)
171
+ # - Rationale: Tables are structured data where the header defines meaning.
172
+ # Users need to see the header and first rows to understand the data.
173
+ # Showing the "most recent" rows without context is meaningless.
174
+ # - Example: A file listing table - seeing the last 10 files without the
175
+ # header columns (name, size, date) is useless.
176
+ # - NOTE: This is overridden when prefer_recent=True (streaming mode)
177
+ #
178
+ # 2. STREAMING TEXT: Show MOST RECENT (keep end, remove beginning)
179
+ # - Rationale: In streaming assistant responses, the most recent content
180
+ # is usually the most relevant. The user is following along as text
181
+ # appears, so they want to see "where we are now" not "where we started".
182
+ # - Example: A code explanation - seeing the conclusion is more valuable
183
+ # than seeing the introduction paragraph that scrolled off.
184
+ #
185
+ # Detection: Content is considered "table-dominant" if >50% of lines are
186
+ # part of table structures (see _is_primary_content_table).
187
+ # OVERRIDE: When prefer_recent=True, always use "show most recent" strategy.
188
+ # ============================================================================
189
+
190
+ # Note: is_table_content was already determined above before calling _find_best_truncation_point
191
+
192
+ if is_table_content:
193
+ # For tables: keep BEGINNING, truncate END (show first N rows)
194
+ # Use safe point as END boundary, keep everything before it
195
+ truncated_text = text[: best_point.char_position]
196
+
197
+ # ========================================================================
198
+ # TABLE HEADER INTEGRITY CHECK
199
+ # ========================================================================
200
+ # Markdown tables require both a header row AND a separator line:
201
+ #
202
+ # | Name | Size | Date | <-- Header row
203
+ # |---------|------|------------| <-- Separator (required!)
204
+ # | file.py | 2KB | 2024-01-15 | <-- Data rows
205
+ #
206
+ # If we truncate between the header and separator, the table won't
207
+ # render at all in markdown. So we need to ensure both are present.
208
+ # ========================================================================
209
+ if truncated_text.strip() and "|" in truncated_text:
210
+ lines_result = truncated_text.split("\n")
211
+ # Check if we have header but missing separator (dashes)
212
+ has_header = any("|" in line and "---" not in line for line in lines_result)
213
+ has_separator = any("---" in line for line in lines_result)
214
+
215
+ if has_header and not has_separator:
216
+ # We cut off the separator! Find it in original and include it
217
+ original_lines = text.split("\n")
218
+ for i, line in enumerate(original_lines):
219
+ if "---" in line and "|" in line:
220
+ # Found separator line - include up to and including this line
221
+ truncated_text = "\n".join(original_lines[: i + 1])
222
+ break
223
+ else:
224
+ # ========================================================================
225
+ # STREAMING TEXT: Keep END, truncate BEGINNING (show most recent)
226
+ # ========================================================================
227
+ # This is the primary use case: assistant is streaming a response, and
228
+ # the terminal can't show all of it. We want to show what's currently
229
+ # being written (the end), not what was written minutes ago (the start).
230
+ # ========================================================================
231
+ truncated_text = text[best_point.char_position :]
232
+
233
+ # ========================================================================
234
+ # CONTEXT PRESERVATION for Truncated Structures
235
+ # ========================================================================
236
+ # When truncating removes the beginning of a structure (code block or
237
+ # table), we need to restore the opening context so it renders properly.
238
+ #
239
+ # CODE BLOCKS: If we truncate mid-block, prepend the opening fence
240
+ # Original: ```python\ndef foo():\n return 42\n```
241
+ # Truncate: [```python removed] def foo():\n return 42\n```
242
+ # Fixed: ```python\ndef foo():\n return 42\n```
243
+ #
244
+ # TABLES: If we truncate table data rows, prepend the header
245
+ # Original: | Name | Size |\n|------|------|\n| a | 1 |\n| b | 2 |
246
+ # Truncate: [header removed] | b | 2 |
247
+ # Fixed: | Name | Size |\n|------|------|\n| b | 2 |
248
+ # ========================================================================
249
+
250
+ # Get code block info once for efficient position-based checks
251
+ code_blocks = self._get_code_block_info(text)
252
+
253
+ # Find which code block (if any) contains the truncation point
254
+ containing_code_block = None
255
+ for block in code_blocks:
256
+ if block.start_pos < best_point.char_position < block.end_pos:
257
+ containing_code_block = block
258
+ break
259
+
260
+ # Check if we need special handling for code blocks
261
+ if containing_code_block:
262
+ truncated_text = self._handle_code_block_truncation(
263
+ containing_code_block, best_point, truncated_text
264
+ )
265
+
266
+ # Get table info once for efficient position-based checks
267
+ tables = self._get_table_info(text)
268
+
269
+ # Find ANY table whose content is in the truncated text but whose header was removed
270
+ for table in tables:
271
+ # Check if we truncated somewhere within this table (after the start)
272
+ # and the truncated text still contains part of this table
273
+ if (
274
+ best_point.char_position > table.start_pos
275
+ and best_point.char_position < table.end_pos
276
+ ):
277
+ # We truncated within this table
278
+ # Check if the header was removed
279
+ # Use >= because if we truncate AT thead_end_pos, the header is already gone
280
+ if best_point.char_position >= table.thead_end_pos:
281
+ # Header was removed - prepend it
282
+ header_text = "\n".join(table.header_lines) + "\n"
283
+ truncated_text = header_text + truncated_text
284
+ break # Only restore one table header
285
+
286
+ return truncated_text
287
+
288
+ def _find_safe_truncation_points(self, text: str) -> List[TruncationPoint]:
289
+ """Find safe positions to truncate at (block boundaries).
290
+
291
+ Args:
292
+ text: The markdown text to analyze.
293
+
294
+ Returns:
295
+ List of TruncationPoint objects representing safe truncation positions.
296
+ """
297
+ tokens = self.parser.parse(text)
298
+ safe_points = []
299
+
300
+ # Don't flatten - we need to process top-level tokens
301
+ lines = text.split("\n")
302
+
303
+ for token in tokens:
304
+ # We're interested in block-level tokens with map information
305
+ # Opening tokens (nesting=1) and self-closing tokens (nesting=0) have map info
306
+ if token.map is not None:
307
+ # token.map gives [start_line, end_line] (0-indexed)
308
+ end_line = token.map[1]
309
+
310
+ # Calculate character position at end of this block
311
+ if end_line <= len(lines):
312
+ char_pos = sum(len(line) + 1 for line in lines[:end_line])
313
+
314
+ safe_points.append(
315
+ TruncationPoint(
316
+ char_position=char_pos,
317
+ block_type=token.type,
318
+ token=token,
319
+ is_closing=(token.nesting == 0), # Self-closing or block end
320
+ )
321
+ )
322
+
323
+ return safe_points
324
+
325
+ def _get_code_block_info(self, text: str) -> List[CodeBlockInfo]:
326
+ """Extract code block positions and metadata using markdown-it.
327
+
328
+ Uses same technique as _prepare_markdown_content in console_display.py:
329
+ parse once with markdown-it, extract exact positions from tokens.
330
+
331
+ Args:
332
+ text: The markdown text to analyze.
333
+
334
+ Returns:
335
+ List of CodeBlockInfo objects with position and language metadata.
336
+ """
337
+ tokens = self.parser.parse(text)
338
+ lines = text.split("\n")
339
+ code_blocks = []
340
+
341
+ for token in self._flatten_tokens(tokens):
342
+ if token.type in ("fence", "code_block") and token.map:
343
+ start_line = token.map[0]
344
+ end_line = token.map[1]
345
+ start_pos = sum(len(line) + 1 for line in lines[:start_line])
346
+ end_pos = sum(len(line) + 1 for line in lines[:end_line])
347
+ language = token.info or "" if hasattr(token, "info") else ""
348
+
349
+ code_blocks.append(
350
+ CodeBlockInfo(
351
+ start_pos=start_pos,
352
+ end_pos=end_pos,
353
+ fence_line=start_line,
354
+ language=language,
355
+ token=token,
356
+ )
357
+ )
358
+
359
+ return code_blocks
360
+
361
+ def _get_table_info(self, text: str) -> List[TableInfo]:
362
+ """Extract table positions and metadata using markdown-it.
363
+
364
+ Uses same technique as _get_code_block_info: parse once with markdown-it,
365
+ extract exact positions from tokens.
366
+
367
+ Args:
368
+ text: The markdown text to analyze.
369
+
370
+ Returns:
371
+ List of TableInfo objects with position and header metadata.
372
+ """
373
+ tokens = self.parser.parse(text)
374
+ lines = text.split("\n")
375
+ tables = []
376
+
377
+ for i, token in enumerate(tokens):
378
+ if token.type == "table_open" and token.map:
379
+ # Find thead and tbody within this table
380
+ thead_start_line = None
381
+ thead_end_line = None
382
+ tbody_start_line = None
383
+ tbody_end_line = None
384
+
385
+ # Look ahead in tokens to find thead and tbody
386
+ for j in range(i + 1, len(tokens)):
387
+ if tokens[j].type == "thead_open" and tokens[j].map:
388
+ thead_start_line = tokens[j].map[0]
389
+ thead_end_line = tokens[j].map[1]
390
+ elif tokens[j].type == "tbody_open" and tokens[j].map:
391
+ tbody_start_line = tokens[j].map[0]
392
+ tbody_end_line = tokens[j].map[1]
393
+ elif tokens[j].type == "table_close":
394
+ # End of this table
395
+ break
396
+
397
+ # Check if we have both thead and tbody
398
+ if (
399
+ thead_start_line is not None
400
+ and thead_end_line is not None
401
+ and tbody_start_line is not None
402
+ and tbody_end_line is not None
403
+ ):
404
+ # Calculate character positions
405
+ table_start_line = token.map[0]
406
+ table_end_line = token.map[1]
407
+
408
+ # markdown-it reports table_start_line as pointing to the HEADER ROW,
409
+ # not the separator. So table_start_line should already be correct.
410
+ # We just need to capture from table_start_line to tbody_start_line
411
+ # to get both the header row and separator row.
412
+ actual_table_start_line = table_start_line
413
+
414
+ table_start_pos = sum(len(line) + 1 for line in lines[:actual_table_start_line])
415
+ table_end_pos = sum(len(line) + 1 for line in lines[:table_end_line])
416
+ thead_start_pos = sum(len(line) + 1 for line in lines[:thead_start_line])
417
+ thead_end_pos = sum(len(line) + 1 for line in lines[:thead_end_line])
418
+ tbody_start_pos = sum(len(line) + 1 for line in lines[:tbody_start_line])
419
+ tbody_end_pos = sum(len(line) + 1 for line in lines[:tbody_end_line])
420
+
421
+ # Extract header lines (header row + separator)
422
+ # table_start_line points to the header row,
423
+ # and tbody_start_line is where data rows start.
424
+ # So lines[table_start_line:tbody_start_line] gives us both header and separator
425
+ header_lines = lines[actual_table_start_line:tbody_start_line]
426
+
427
+ tables.append(
428
+ TableInfo(
429
+ start_pos=table_start_pos,
430
+ end_pos=table_end_pos,
431
+ thead_start_pos=thead_start_pos,
432
+ thead_end_pos=thead_end_pos,
433
+ tbody_start_pos=tbody_start_pos,
434
+ tbody_end_pos=tbody_end_pos,
435
+ header_lines=header_lines,
436
+ )
437
+ )
438
+
439
+ return tables
440
+
441
+ def _find_best_truncation_point(
442
+ self,
443
+ text: str,
444
+ safe_points: List[TruncationPoint],
445
+ target_height: int,
446
+ console: Console,
447
+ code_theme: str,
448
+ keep_beginning: bool = False,
449
+ ) -> Optional[TruncationPoint]:
450
+ """Find the truncation point that gets closest to target height.
451
+
452
+ Args:
453
+ text: The full markdown text.
454
+ safe_points: List of potential truncation points.
455
+ target_height: Target height in terminal lines.
456
+ console: Rich Console for measuring.
457
+ code_theme: Code syntax highlighting theme.
458
+ keep_beginning: If True, test keeping text BEFORE point (table mode).
459
+ If False, test keeping text AFTER point (streaming mode).
460
+
461
+ Returns:
462
+ The best TruncationPoint, or None if none work.
463
+ """
464
+ best_point = None
465
+ best_diff = float("inf")
466
+
467
+ for point in safe_points:
468
+ # Test truncating at this point
469
+ # Direction depends on truncation strategy
470
+ if keep_beginning:
471
+ # Table mode: keep beginning, remove end
472
+ truncated = text[: point.char_position]
473
+ else:
474
+ # Streaming mode: keep end, remove beginning
475
+ truncated = text[point.char_position :]
476
+
477
+ # Skip if truncation would result in empty or nearly empty text
478
+ if not truncated.strip():
479
+ continue
480
+
481
+ height = self._measure_rendered_height(truncated, console, code_theme)
482
+
483
+ # Calculate how far we are from target
484
+ diff = abs(height - target_height)
485
+
486
+ # We prefer points that keep us at or below target
487
+ if height <= target_height and diff < best_diff:
488
+ best_point = point
489
+ best_diff = diff
490
+
491
+ return best_point
492
+
493
+ def _truncate_by_characters(
494
+ self, text: str, target_height: int, console: Console, code_theme: str
495
+ ) -> str:
496
+ """Fall back to character-based truncation using binary search.
497
+
498
+ This is used when no safe block boundary works (e.g., single block too large).
499
+
500
+ Args:
501
+ text: The markdown text to truncate.
502
+ target_height: Target height in terminal lines.
503
+ console: Rich Console for measuring.
504
+ code_theme: Code syntax highlighting theme.
505
+
506
+ Returns:
507
+ Truncated text that fits within target height.
508
+ """
509
+ if not text:
510
+ return text
511
+
512
+ # Binary search on character position
513
+ left, right = 0, len(text) - 1
514
+ best_pos = None
515
+
516
+ while left <= right:
517
+ mid = (left + right) // 2
518
+ test_text = text[mid:]
519
+
520
+ if not test_text.strip():
521
+ # Skip empty results
522
+ right = mid - 1
523
+ continue
524
+
525
+ height = self._measure_rendered_height(test_text, console, code_theme)
526
+
527
+ if height <= target_height:
528
+ # Can keep more text - try removing less
529
+ best_pos = mid
530
+ right = mid - 1
531
+ else:
532
+ # Need to truncate more
533
+ left = mid + 1
534
+
535
+ # If nothing fits at all, return the last portion of text that's minimal
536
+ if best_pos is None:
537
+ # Return last few characters or lines that might fit
538
+ # Take approximately the last 20% of the text as a fallback
539
+ fallback_pos = int(len(text) * 0.8)
540
+ return text[fallback_pos:] if fallback_pos < len(text) else text
541
+
542
+ return text[best_pos:]
543
+
544
+ def measure_rendered_height(
545
+ self, text: str, console: Console, code_theme: str = "monokai"
546
+ ) -> int:
547
+ """Public helper that measures rendered height for markdown content."""
548
+ return self._measure_rendered_height(text, console, code_theme)
549
+
550
+ def _handle_code_block_truncation(
551
+ self, code_block: CodeBlockInfo, truncation_point: TruncationPoint, truncated_text: str
552
+ ) -> str:
553
+ """Handle truncation within a code block by preserving the opening fence.
554
+
555
+ When truncating within a code block, we need to ensure the opening fence
556
+ (```language) is preserved so the remaining content renders correctly.
557
+
558
+ This uses a simple position-based approach: if the truncation point is after
559
+ the fence's starting position, the fence has scrolled off and needs to be
560
+ prepended. Otherwise, it's still on screen.
561
+
562
+ Args:
563
+ code_block: The CodeBlockInfo for the block being truncated.
564
+ truncation_point: Where we're truncating.
565
+ truncated_text: The text after truncation.
566
+
567
+ Returns:
568
+ Modified truncated text with code fence preserved if needed.
569
+ """
570
+ # Simple check: did we remove the opening fence?
571
+ # If truncation happened after the fence line, it scrolled off
572
+ if truncation_point.char_position > code_block.start_pos:
573
+ # Check if fence is already at the beginning (avoid duplicates)
574
+ fence = f"```{code_block.language}\n"
575
+ if not truncated_text.startswith(fence):
576
+ # Fence scrolled off - prepend it
577
+ return fence + truncated_text
578
+
579
+ # Fence still on screen or already prepended - keep as-is
580
+ return truncated_text
581
+
582
+ def _ensure_code_fence_if_needed(self, original_text: str, truncated_text: str) -> str:
583
+ """Ensure code fence is prepended if truncation happened within a code block.
584
+
585
+ This is used after character-based truncation to check if we need to add
586
+ a code fence to the beginning of the truncated text.
587
+
588
+ Uses the same position-based approach as _handle_code_block_truncation.
589
+
590
+ Args:
591
+ original_text: The original full text before truncation.
592
+ truncated_text: The truncated text.
593
+
594
+ Returns:
595
+ Truncated text with code fence prepended if needed.
596
+ """
597
+ if not truncated_text or truncated_text == original_text:
598
+ return truncated_text
599
+
600
+ # Find where the truncated text starts in the original
601
+ truncation_pos = original_text.rfind(truncated_text)
602
+ if truncation_pos == -1:
603
+ truncation_pos = max(0, len(original_text) - len(truncated_text))
604
+
605
+ # Get code block info using markdown-it parser
606
+ code_blocks = self._get_code_block_info(original_text)
607
+
608
+ # Find which code block (if any) contains the truncation point
609
+ for block in code_blocks:
610
+ if block.start_pos < truncation_pos < block.end_pos:
611
+ # Truncated within this code block
612
+ # Simple check: did truncation remove the fence?
613
+ if truncation_pos > block.start_pos:
614
+ # Check if fence is already at the beginning (avoid duplicates)
615
+ fence = f"```{block.language}\n"
616
+ if not truncated_text.startswith(fence):
617
+ # Fence scrolled off - prepend it
618
+ return fence + truncated_text
619
+ # Fence still on screen or already prepended
620
+ return truncated_text
621
+
622
+ return truncated_text
623
+
624
+ def _ensure_table_header_if_needed(self, original_text: str, truncated_text: str) -> str:
625
+ """Ensure table header is prepended if truncation happened within a table body.
626
+
627
+ When truncating within a table body, we need to preserve the header row(s)
628
+ so the remaining table rows have context and meaning.
629
+
630
+ Uses the same position-based approach as code block handling.
631
+
632
+ Args:
633
+ original_text: The original full text before truncation.
634
+ truncated_text: The truncated text.
635
+
636
+ Returns:
637
+ Truncated text with table header prepended if needed.
638
+ """
639
+ if not truncated_text or truncated_text == original_text:
640
+ return truncated_text
641
+
642
+ # Find where the truncated text starts in the original
643
+ truncation_pos = original_text.rfind(truncated_text)
644
+ if truncation_pos == -1:
645
+ truncation_pos = max(0, len(original_text) - len(truncated_text))
646
+
647
+ # Get table info using markdown-it parser
648
+ tables = self._get_table_info(original_text)
649
+
650
+ # Find which table (if any) contains the truncation point in tbody
651
+ for table in tables:
652
+ # Check if truncation happened within tbody (after thead)
653
+ if table.thead_end_pos <= truncation_pos < table.tbody_end_pos:
654
+ # Truncated within table body
655
+ # Simple check: did truncation remove the header?
656
+ # Use >= because if we truncate AT thead_end_pos, the header is already gone
657
+ if truncation_pos >= table.thead_end_pos:
658
+ # Header completely scrolled off - prepend it
659
+ header_text = "\n".join(table.header_lines) + "\n"
660
+ return header_text + truncated_text
661
+ else:
662
+ # Header still on screen
663
+ return truncated_text
664
+
665
+ return truncated_text
666
+
667
+ def _is_primary_content_table(self, text: str) -> bool:
668
+ """Check if the document's primary content is a table.
669
+
670
+ This heuristic determines if we should use "show first page" truncation
671
+ (for tables) vs "show most recent" truncation (for streaming text).
672
+
673
+ Detection Logic:
674
+ ----------------
675
+ A document is considered "table-dominant" if MORE THAN 50% of its lines
676
+ are part of table structures.
677
+
678
+ Why 50%?
679
+ - Below 50%: Content is mostly text with some tables mixed in.
680
+ Show most recent (standard streaming behavior).
681
+ - Above 50%: Content is primarily tabular data.
682
+ Show beginning so users see the header defining the columns.
683
+
684
+ Examples:
685
+ ---------
686
+ TABLE-DOMINANT (>50%, will show first page):
687
+ | Name | Size |
688
+ |------|------|
689
+ | a | 1 |
690
+ | b | 2 |
691
+ | c | 3 |
692
+ (5 lines, 5 table lines = 100% table)
693
+
694
+ NOT TABLE-DOMINANT (≤50%, will show most recent):
695
+ Here's a file listing:
696
+ | Name | Size |
697
+ |------|------|
698
+ | a | 1 |
699
+ This shows the files in the directory.
700
+ (6 lines, 3 table lines = 50% table)
701
+
702
+ Args:
703
+ text: The full markdown text.
704
+
705
+ Returns:
706
+ True if document is primarily a table (table content > 50% of lines).
707
+ """
708
+ if not text.strip():
709
+ return False
710
+
711
+ tokens = self.parser.parse(text)
712
+ lines = text.split("\n")
713
+ total_lines = len(lines)
714
+
715
+ if total_lines == 0:
716
+ return False
717
+
718
+ # Count lines that are part of tables
719
+ table_lines = 0
720
+ for token in tokens:
721
+ if token.type == "table_open" and token.map:
722
+ start_line = token.map[0]
723
+ end_line = token.map[1]
724
+ table_lines += end_line - start_line
725
+
726
+ # If more than 50% of content is table, consider it table-dominant
727
+ return table_lines > (total_lines * 0.5)
728
+
729
+ def _measure_rendered_height(self, text: str, console: Console, code_theme: str) -> int:
730
+ """Measure how many terminal lines the rendered markdown takes.
731
+
732
+ Args:
733
+ text: The markdown text to measure.
734
+ console: Rich Console for rendering.
735
+ code_theme: Code syntax highlighting theme.
736
+
737
+ Returns:
738
+ Height in terminal lines.
739
+ """
740
+ if not text.strip():
741
+ return 0
742
+
743
+ md = Markdown(text, code_theme=code_theme)
744
+ options = console.options
745
+ lines = console.render_lines(md, options)
746
+ _, height = Segment.get_shape(lines)
747
+
748
+ return height
749
+
750
+ def _truncate_streaming(
751
+ self,
752
+ text: str,
753
+ terminal_height: int,
754
+ console: Console,
755
+ code_theme: str = "monokai",
756
+ ) -> str:
757
+ """Fast truncation optimized for streaming mode.
758
+
759
+ This method uses a line-based rolling window approach that avoids
760
+ redundant parsing and rendering. It's designed for the common case
761
+ where content is continuously growing and we want to show the most
762
+ recent portion.
763
+
764
+ Key optimizations:
765
+ 1. Incremental: Only processes new content since last call
766
+ 2. Line-based: Uses fast line counting instead of full renders
767
+ 3. Single-pass: Only one render at the end to verify fit
768
+
769
+ Args:
770
+ text: The markdown text to truncate.
771
+ terminal_height: Height of the terminal in lines.
772
+ console: Rich Console for rendering.
773
+ code_theme: Code syntax highlighting theme.
774
+
775
+ Returns:
776
+ Truncated text showing the most recent content.
777
+ """
778
+ if not text:
779
+ return text
780
+
781
+ target_height = int(terminal_height * self.target_height_ratio)
782
+
783
+ # Check if we can use cached result
784
+ if (
785
+ self._last_full_text is not None
786
+ and text.startswith(self._last_truncated_text or "")
787
+ and self._last_terminal_height == terminal_height
788
+ ):
789
+ # Text only grew at the end, we can be more efficient
790
+ # But for simplicity in first version, just proceed with normal flow
791
+ pass
792
+
793
+ # Fast line-based estimation
794
+ # Strategy: Keep approximately 2x target lines as a generous buffer
795
+ # This avoids most cases where we need multiple render passes
796
+ lines = text.split('\n')
797
+ total_lines = len(lines)
798
+
799
+ # Rough heuristic: markdown usually expands by 1.5-2x due to formatting
800
+ # So to get target_height rendered lines, keep ~target_height raw lines
801
+ estimated_raw_lines = int(target_height * 1.2) # Conservative estimate
802
+
803
+ if total_lines <= estimated_raw_lines:
804
+ # Likely fits, just verify with single render
805
+ height = self._measure_rendered_height(text, console, code_theme)
806
+ if height <= terminal_height:
807
+ self._update_cache(text, text, terminal_height)
808
+ return text
809
+ # Didn't fit, fall through to truncation
810
+
811
+ # Keep last N lines as initial guess
812
+ keep_lines = min(estimated_raw_lines, total_lines)
813
+ truncated_lines = lines[-keep_lines:]
814
+ truncated_text = '\n'.join(truncated_lines)
815
+
816
+ # Check for incomplete structures and fix them
817
+ truncated_text = self._fix_incomplete_structures(text, truncated_text)
818
+
819
+ # Verify it fits (single render)
820
+ height = self._measure_rendered_height(truncated_text, console, code_theme)
821
+
822
+ # If it doesn't fit, trim more aggressively
823
+ if height > terminal_height:
824
+ # Binary search on line count (much faster than character-based)
825
+ left, right = 0, keep_lines
826
+ best_lines = None
827
+
828
+ while left <= right:
829
+ mid = (left + right) // 2
830
+ test_lines = lines[-mid:] if mid > 0 else []
831
+ test_text = '\n'.join(test_lines)
832
+
833
+ if not test_text.strip():
834
+ right = mid - 1
835
+ continue
836
+
837
+ # Fix structures before measuring
838
+ test_text = self._fix_incomplete_structures(text, test_text)
839
+ test_height = self._measure_rendered_height(test_text, console, code_theme)
840
+
841
+ if test_height <= terminal_height:
842
+ best_lines = mid
843
+ left = mid + 1 # Try to keep more
844
+ else:
845
+ right = mid - 1 # Need to keep less
846
+
847
+ if best_lines is not None and best_lines > 0:
848
+ truncated_lines = lines[-best_lines:]
849
+ truncated_text = '\n'.join(truncated_lines)
850
+ truncated_text = self._fix_incomplete_structures(text, truncated_text)
851
+ else:
852
+ # Extreme case: even one line is too much
853
+ # Keep last 20% of text as fallback
854
+ fallback_pos = int(len(text) * 0.8)
855
+ truncated_text = text[fallback_pos:]
856
+ truncated_text = self._fix_incomplete_structures(text, truncated_text)
857
+
858
+ self._update_cache(text, truncated_text, terminal_height)
859
+ return truncated_text
860
+
861
+ def _fix_incomplete_structures(self, original_text: str, truncated_text: str) -> str:
862
+ """Fix incomplete markdown structures after line-based truncation.
863
+
864
+ Handles:
865
+ - Code blocks missing opening fence
866
+ - Tables missing headers
867
+
868
+ Args:
869
+ original_text: The original full text.
870
+ truncated_text: The truncated text that may have incomplete structures.
871
+
872
+ Returns:
873
+ Fixed truncated text with structures completed.
874
+ """
875
+ if not truncated_text or truncated_text == original_text:
876
+ return truncated_text
877
+
878
+ # Find where the truncated text starts in the original
879
+ truncation_pos = original_text.find(truncated_text)
880
+ if truncation_pos == -1:
881
+ # Can't find it, return as-is
882
+ return truncated_text
883
+
884
+ # Check for incomplete code blocks
885
+ original_fence_count = original_text[:truncation_pos].count('```')
886
+
887
+ # If we removed an odd number of fences, we're inside a code block
888
+ if original_fence_count % 2 == 1:
889
+ # Find the last opening fence before truncation point
890
+ import re
891
+ before_truncation = original_text[:truncation_pos]
892
+ fences = list(re.finditer(r'^```(\w*)', before_truncation, re.MULTILINE))
893
+ if fences:
894
+ last_fence = fences[-1]
895
+ language = last_fence.group(1) if last_fence.group(1) else ''
896
+ fence = f'```{language}\n'
897
+ if not truncated_text.startswith(fence):
898
+ truncated_text = fence + truncated_text
899
+
900
+ # Check for incomplete tables
901
+ # Only if we're not inside a code block
902
+ if original_fence_count % 2 == 0 and '|' in truncated_text:
903
+ # Use the existing table header restoration logic
904
+ tables = self._get_table_info(original_text)
905
+ for table in tables:
906
+ if table.thead_end_pos <= truncation_pos < table.tbody_end_pos:
907
+ # We're in the table body, header was removed
908
+ header_text = "\n".join(table.header_lines) + "\n"
909
+ if not truncated_text.startswith(header_text):
910
+ truncated_text = header_text + truncated_text
911
+ break
912
+
913
+ return truncated_text
914
+
915
+ def _update_cache(self, full_text: str, truncated_text: str, terminal_height: int) -> None:
916
+ """Update the cache for streaming mode.
917
+
918
+ Args:
919
+ full_text: The full text that was truncated.
920
+ truncated_text: The resulting truncated text.
921
+ terminal_height: The terminal height used.
922
+ """
923
+ self._last_full_text = full_text
924
+ self._last_truncated_text = truncated_text
925
+ self._last_terminal_height = terminal_height
926
+
927
+ def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:
928
+ """Flatten nested token structure.
929
+
930
+ Args:
931
+ tokens: Iterable of Token objects from markdown-it.
932
+
933
+ Yields:
934
+ Flattened tokens.
935
+ """
936
+ for token in tokens:
937
+ is_fence = token.type == "fence"
938
+ is_image = token.tag == "img"
939
+ if token.children and not (is_image or is_fence):
940
+ yield from self._flatten_tokens(token.children)
941
+ else:
942
+ yield token