fast-agent-mcp 0.4.7__py3-none-any.whl

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