fast-agent-mcp 0.3.14__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 +45 -4
- fast_agent/agents/mcp_agent.py +3 -3
- fast_agent/agents/tool_agent.py +33 -19
- fast_agent/agents/workflow/router_agent.py +2 -1
- fast_agent/cli/commands/check_config.py +5 -2
- fast_agent/config.py +2 -2
- fast_agent/core/fastagent.py +14 -1
- fast_agent/core/validation.py +31 -33
- fast_agent/human_input/form_fields.py +4 -1
- fast_agent/interfaces.py +4 -1
- fast_agent/llm/fastagent_llm.py +31 -0
- fast_agent/llm/model_database.py +2 -2
- fast_agent/llm/model_factory.py +4 -1
- fast_agent/mcp/prompt_message_extended.py +2 -2
- fast_agent/resources/setup/fastagent.config.yaml +5 -4
- fast_agent/ui/console_display.py +654 -69
- fast_agent/ui/elicitation_form.py +23 -1
- fast_agent/ui/enhanced_prompt.py +49 -3
- fast_agent/ui/markdown_truncator.py +942 -0
- fast_agent/ui/mcp_display.py +2 -2
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/METADATA +4 -3
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/RECORD +27 -24
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.14.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
|