iCode-TUI 0.29.1__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 (1138) hide show
  1. chrys/__init__.py +16 -0
  2. chrys/app/__init__.py +3 -0
  3. chrys/app/acp/__init__.py +3 -0
  4. chrys/app/acp/bridge.py +664 -0
  5. chrys/app/acp/content.py +132 -0
  6. chrys/app/acp/doc/frontend-api.md +425 -0
  7. chrys/app/acp/history.py +585 -0
  8. chrys/app/acp/server.py +1796 -0
  9. chrys/app/acp/session_manager.py +1815 -0
  10. chrys/app/acp/tool_status.py +45 -0
  11. chrys/app/cli/__init__.py +3 -0
  12. chrys/app/cli/acp.py +178 -0
  13. chrys/app/cli/app.py +165 -0
  14. chrys/app/cli/approvals.py +101 -0
  15. chrys/app/cli/headless.py +223 -0
  16. chrys/app/cli/launch_cwd.py +42 -0
  17. chrys/app/cli/profiles.py +298 -0
  18. chrys/app/cli/progress.py +857 -0
  19. chrys/app/cli/run.py +425 -0
  20. chrys/app/cli/serve.py +1593 -0
  21. chrys/app/cli/tool_summary.py +225 -0
  22. chrys/app/cli/trajectory.py +201 -0
  23. chrys/app/cli/workflow.py +601 -0
  24. chrys/app/features/__init__.py +3 -0
  25. chrys/app/features/buddy/__init__.py +3 -0
  26. chrys/app/features/buddy/actions.py +68 -0
  27. chrys/app/features/buddy/animation.py +76 -0
  28. chrys/app/features/buddy/commands.py +196 -0
  29. chrys/app/features/buddy/docs/design.md +187 -0
  30. chrys/app/features/buddy/hatchery.py +80 -0
  31. chrys/app/features/buddy/lifecycle.py +41 -0
  32. chrys/app/features/buddy/model.py +265 -0
  33. chrys/app/features/buddy/pixel_renderer.py +113 -0
  34. chrys/app/features/buddy/pixel_sprites.py +279 -0
  35. chrys/app/features/buddy/portrait.py +76 -0
  36. chrys/app/features/buddy/progression.py +58 -0
  37. chrys/app/features/buddy/replies.py +191 -0
  38. chrys/app/features/buddy/sprites/alien.toml +70 -0
  39. chrys/app/features/buddy/sprites/axolotl.toml +71 -0
  40. chrys/app/features/buddy/sprites/bat.toml +69 -0
  41. chrys/app/features/buddy/sprites/bee.toml +69 -0
  42. chrys/app/features/buddy/sprites/cactus.toml +73 -0
  43. chrys/app/features/buddy/sprites/capybara.toml +70 -0
  44. chrys/app/features/buddy/sprites/cat.toml +69 -0
  45. chrys/app/features/buddy/sprites/crab.toml +70 -0
  46. chrys/app/features/buddy/sprites/dragon.toml +71 -0
  47. chrys/app/features/buddy/sprites/duck.toml +69 -0
  48. chrys/app/features/buddy/sprites/fox.toml +69 -0
  49. chrys/app/features/buddy/sprites/frog.toml +70 -0
  50. chrys/app/features/buddy/sprites/ghost.toml +70 -0
  51. chrys/app/features/buddy/sprites/goose.toml +69 -0
  52. chrys/app/features/buddy/sprites/jelly.toml +70 -0
  53. chrys/app/features/buddy/sprites/llama.toml +70 -0
  54. chrys/app/features/buddy/sprites/mushroom.toml +69 -0
  55. chrys/app/features/buddy/sprites/octopus.toml +70 -0
  56. chrys/app/features/buddy/sprites/owl.toml +70 -0
  57. chrys/app/features/buddy/sprites/panda.toml +71 -0
  58. chrys/app/features/buddy/sprites/penguin.toml +69 -0
  59. chrys/app/features/buddy/sprites/rabbit.toml +70 -0
  60. chrys/app/features/buddy/sprites/robot.toml +72 -0
  61. chrys/app/features/buddy/sprites/shark.toml +70 -0
  62. chrys/app/features/buddy/sprites/snail.toml +69 -0
  63. chrys/app/features/buddy/sprites/snake.toml +69 -0
  64. chrys/app/features/buddy/sprites/tabby.toml +69 -0
  65. chrys/app/features/buddy/sprites/turtle.toml +69 -0
  66. chrys/app/features/buddy/store.py +143 -0
  67. chrys/app/features/session_title/__init__.py +7 -0
  68. chrys/app/features/session_title/updater.py +582 -0
  69. chrys/app/installer.py +651 -0
  70. chrys/app/parsing.py +17 -0
  71. chrys/app/tui/__init__.py +3 -0
  72. chrys/app/tui/app.py +1287 -0
  73. chrys/app/tui/behaviors/__init__.py +3 -0
  74. chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
  75. chrys/app/tui/behaviors/insert_clipboard.py +73 -0
  76. chrys/app/tui/behaviors/right_click_copy.py +146 -0
  77. chrys/app/tui/binding_display.py +139 -0
  78. chrys/app/tui/buddy_messages.py +17 -0
  79. chrys/app/tui/buddy_reply.py +86 -0
  80. chrys/app/tui/chrys.tcss +549 -0
  81. chrys/app/tui/clipboard.py +109 -0
  82. chrys/app/tui/copy_messages.py +10 -0
  83. chrys/app/tui/i18n.py +230 -0
  84. chrys/app/tui/language.py +58 -0
  85. chrys/app/tui/notifications/__init__.py +12 -0
  86. chrys/app/tui/notifications/drivers.py +180 -0
  87. chrys/app/tui/notifications/service.py +126 -0
  88. chrys/app/tui/notifications/settings.py +141 -0
  89. chrys/app/tui/screens/__init__.py +3 -0
  90. chrys/app/tui/screens/agents/__init__.py +8 -0
  91. chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
  92. chrys/app/tui/screens/agents/config.py +2142 -0
  93. chrys/app/tui/screens/agents/config.tcss +169 -0
  94. chrys/app/tui/screens/agents/panels/__init__.py +3 -0
  95. chrys/app/tui/screens/agents/panels/acp.py +1176 -0
  96. chrys/app/tui/screens/agents/panels/basic.py +497 -0
  97. chrys/app/tui/screens/agents/panels/compaction.py +231 -0
  98. chrys/app/tui/screens/agents/panels/config_card.py +218 -0
  99. chrys/app/tui/screens/agents/panels/instructions.py +91 -0
  100. chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
  101. chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
  102. chrys/app/tui/screens/agents/panels/memory.py +684 -0
  103. chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
  104. chrys/app/tui/screens/agents/panels/skills.py +806 -0
  105. chrys/app/tui/screens/agents/panels/subagents.py +652 -0
  106. chrys/app/tui/screens/agents/panels/tools.py +205 -0
  107. chrys/app/tui/screens/agents/picker.py +103 -0
  108. chrys/app/tui/screens/agents/skill_paths.py +38 -0
  109. chrys/app/tui/screens/agents/validation_messages.py +452 -0
  110. chrys/app/tui/screens/dialogs/__init__.py +35 -0
  111. chrys/app/tui/screens/dialogs/agent_load.py +834 -0
  112. chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
  113. chrys/app/tui/screens/dialogs/app_mode.py +55 -0
  114. chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
  115. chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
  116. chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
  117. chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
  118. chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
  119. chrys/app/tui/screens/dialogs/approval/body.py +110 -0
  120. chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
  121. chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
  122. chrys/app/tui/screens/dialogs/ask_user.py +148 -0
  123. chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
  124. chrys/app/tui/screens/dialogs/base.py +84 -0
  125. chrys/app/tui/screens/dialogs/confirm.py +184 -0
  126. chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
  127. chrys/app/tui/screens/dialogs/connection_test.py +183 -0
  128. chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
  129. chrys/app/tui/screens/dialogs/diagram.py +102 -0
  130. chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
  131. chrys/app/tui/screens/dialogs/editor.py +378 -0
  132. chrys/app/tui/screens/dialogs/editor.tcss +132 -0
  133. chrys/app/tui/screens/dialogs/file_picker.py +582 -0
  134. chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
  135. chrys/app/tui/screens/dialogs/fork_session.py +219 -0
  136. chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
  137. chrys/app/tui/screens/dialogs/image_compression.py +102 -0
  138. chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
  139. chrys/app/tui/screens/dialogs/man_page.py +172 -0
  140. chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
  141. chrys/app/tui/screens/dialogs/new_folder.py +277 -0
  142. chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
  143. chrys/app/tui/screens/dialogs/notice.tcss +14 -0
  144. chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
  145. chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
  146. chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
  147. chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
  148. chrys/app/tui/screens/dialogs/session_title.py +110 -0
  149. chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
  150. chrys/app/tui/screens/dialogs/tool_view.py +207 -0
  151. chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
  152. chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
  153. chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
  154. chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
  155. chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
  156. chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
  157. chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
  158. chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
  159. chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
  160. chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
  161. chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
  162. chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
  163. chrys/app/tui/screens/diff/__init__.py +22 -0
  164. chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
  165. chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
  166. chrys/app/tui/screens/diff/screen.py +1532 -0
  167. chrys/app/tui/screens/diff/screen.tcss +104 -0
  168. chrys/app/tui/screens/guides/__init__.py +7 -0
  169. chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
  170. chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
  171. chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
  172. chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
  173. chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
  174. chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
  175. chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
  176. chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
  177. chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
  178. chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
  179. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
  180. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
  181. chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
  182. chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
  183. chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
  184. chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
  185. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
  186. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
  187. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
  188. chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
  189. chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
  190. chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
  191. chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
  192. chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
  193. chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
  194. chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
  195. chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
  196. chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
  197. chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
  198. chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
  199. chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
  200. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
  201. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
  202. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
  203. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
  204. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
  205. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
  206. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
  207. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
  208. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
  209. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
  210. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
  211. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
  212. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
  213. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
  214. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
  215. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
  216. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
  217. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
  218. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
  219. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
  220. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
  221. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
  222. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
  223. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
  224. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
  225. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
  226. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
  227. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
  228. chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
  229. chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
  230. chrys/app/tui/screens/guides/index.py +215 -0
  231. chrys/app/tui/screens/guides/reader.py +173 -0
  232. chrys/app/tui/screens/guides/screen.py +442 -0
  233. chrys/app/tui/screens/guides/screen.tcss +52 -0
  234. chrys/app/tui/screens/logs/__init__.py +7 -0
  235. chrys/app/tui/screens/logs/screen.py +504 -0
  236. chrys/app/tui/screens/logs/screen.tcss +45 -0
  237. chrys/app/tui/screens/main/__init__.py +7 -0
  238. chrys/app/tui/screens/main/action_availability.py +30 -0
  239. chrys/app/tui/screens/main/buddy_command.py +143 -0
  240. chrys/app/tui/screens/main/commands.py +1073 -0
  241. chrys/app/tui/screens/main/config_actions.py +464 -0
  242. chrys/app/tui/screens/main/copy_actions.py +117 -0
  243. chrys/app/tui/screens/main/dialog_controllers.py +826 -0
  244. chrys/app/tui/screens/main/dialog_gateway.py +166 -0
  245. chrys/app/tui/screens/main/diff_controller.py +478 -0
  246. chrys/app/tui/screens/main/engine_read_model.py +184 -0
  247. chrys/app/tui/screens/main/event_handlers.py +2014 -0
  248. chrys/app/tui/screens/main/input_flow.py +241 -0
  249. chrys/app/tui/screens/main/live_diff.py +244 -0
  250. chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
  251. chrys/app/tui/screens/main/model_indicator.py +217 -0
  252. chrys/app/tui/screens/main/navigation.py +343 -0
  253. chrys/app/tui/screens/main/ports.py +571 -0
  254. chrys/app/tui/screens/main/recent_dirs.py +123 -0
  255. chrys/app/tui/screens/main/rollback_controller.py +586 -0
  256. chrys/app/tui/screens/main/runtime_info.py +71 -0
  257. chrys/app/tui/screens/main/screen.py +2265 -0
  258. chrys/app/tui/screens/main/screen.tcss +21 -0
  259. chrys/app/tui/screens/main/session_handlers.py +1127 -0
  260. chrys/app/tui/screens/main/session_title.py +239 -0
  261. chrys/app/tui/screens/main/settings_coordinator.py +365 -0
  262. chrys/app/tui/screens/main/settings_persistence.py +184 -0
  263. chrys/app/tui/screens/main/shell_mode.py +112 -0
  264. chrys/app/tui/screens/main/state.py +289 -0
  265. chrys/app/tui/screens/main/submit_state.py +11 -0
  266. chrys/app/tui/screens/main/subscriptions.py +177 -0
  267. chrys/app/tui/screens/main/suggestions.py +982 -0
  268. chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
  269. chrys/app/tui/screens/main/view_adapter.py +1567 -0
  270. chrys/app/tui/screens/main/workflow_browser.py +747 -0
  271. chrys/app/tui/screens/main/workflow_content.py +248 -0
  272. chrys/app/tui/screens/main/workflow_controller.py +240 -0
  273. chrys/app/tui/screens/main/workflow_feedback.py +137 -0
  274. chrys/app/tui/screens/main/workflow_flow.py +49 -0
  275. chrys/app/tui/screens/main/workflow_run_control.py +456 -0
  276. chrys/app/tui/screens/main/workflow_session_view.py +316 -0
  277. chrys/app/tui/screens/main/workspace_actions.py +272 -0
  278. chrys/app/tui/screens/main/workspace_branch.py +194 -0
  279. chrys/app/tui/screens/menus/__init__.py +8 -0
  280. chrys/app/tui/screens/menus/languages.py +114 -0
  281. chrys/app/tui/screens/models/__init__.py +7 -0
  282. chrys/app/tui/screens/models/picker.py +147 -0
  283. chrys/app/tui/screens/models/screen.py +2043 -0
  284. chrys/app/tui/screens/models/screen.tcss +255 -0
  285. chrys/app/tui/screens/sessions/__init__.py +8 -0
  286. chrys/app/tui/screens/sessions/presenter.py +586 -0
  287. chrys/app/tui/screens/sessions/screen.py +779 -0
  288. chrys/app/tui/screens/sessions/screen.tcss +127 -0
  289. chrys/app/tui/screens/settings/__init__.py +29 -0
  290. chrys/app/tui/screens/settings/danger.py +26 -0
  291. chrys/app/tui/screens/settings/dialog.py +281 -0
  292. chrys/app/tui/screens/settings/layout.py +519 -0
  293. chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
  294. chrys/app/tui/screens/settings/panes/__init__.py +3 -0
  295. chrys/app/tui/screens/settings/panes/notifications.py +248 -0
  296. chrys/app/tui/screens/settings/panes/sessions.py +148 -0
  297. chrys/app/tui/screens/settings/ports.py +55 -0
  298. chrys/app/tui/screens/settings/provenance.py +102 -0
  299. chrys/app/tui/screens/settings/rows.py +546 -0
  300. chrys/app/tui/screens/settings/settings.tcss +406 -0
  301. chrys/app/tui/screens/themes/__init__.py +8 -0
  302. chrys/app/tui/screens/themes/dialogs.py +330 -0
  303. chrys/app/tui/screens/themes/editor.py +632 -0
  304. chrys/app/tui/screens/themes/editor.tcss +268 -0
  305. chrys/app/tui/screens/themes/messages.py +95 -0
  306. chrys/app/tui/screens/themes/palette.py +694 -0
  307. chrys/app/tui/screens/themes/panel.py +113 -0
  308. chrys/app/tui/screens/themes/picker.py +203 -0
  309. chrys/app/tui/screens/themes/save.py +191 -0
  310. chrys/app/tui/support/__init__.py +3 -0
  311. chrys/app/tui/support/file_manager.py +64 -0
  312. chrys/app/tui/support/gc_freeze.py +1114 -0
  313. chrys/app/tui/support/path_shortcuts.py +50 -0
  314. chrys/app/tui/support/prompt_history.py +298 -0
  315. chrys/app/tui/support/workspace_mru.py +476 -0
  316. chrys/app/tui/terminal/__init__.py +6 -0
  317. chrys/app/tui/terminal/_pty_backend.py +114 -0
  318. chrys/app/tui/terminal/emulator/__init__.py +24 -0
  319. chrys/app/tui/terminal/emulator/buffer.py +456 -0
  320. chrys/app/tui/terminal/emulator/charsets.py +113 -0
  321. chrys/app/tui/terminal/emulator/core.py +963 -0
  322. chrys/app/tui/terminal/emulator/input.py +342 -0
  323. chrys/app/tui/terminal/emulator/parser.py +227 -0
  324. chrys/app/tui/terminal/emulator/pen.py +158 -0
  325. chrys/app/tui/terminal/launcher.py +455 -0
  326. chrys/app/tui/terminal/leaked_reports.py +44 -0
  327. chrys/app/tui/terminal/panel.py +323 -0
  328. chrys/app/tui/terminal/pty_process.py +405 -0
  329. chrys/app/tui/terminal/rendering.py +151 -0
  330. chrys/app/tui/terminal/shell.py +273 -0
  331. chrys/app/tui/terminal/shell_integration.py +199 -0
  332. chrys/app/tui/terminal/title.py +224 -0
  333. chrys/app/tui/terminal/widget.py +654 -0
  334. chrys/app/tui/theme.py +422 -0
  335. chrys/app/tui/theme_loader.py +201 -0
  336. chrys/app/tui/theme_stylesheet.py +49 -0
  337. chrys/app/tui/themes/__init__.py +3 -0
  338. chrys/app/tui/themes/document.py +120 -0
  339. chrys/app/tui/themes/preview.py +170 -0
  340. chrys/app/tui/themes/store.py +176 -0
  341. chrys/app/tui/toasts.py +69 -0
  342. chrys/app/tui/util/__init__.py +3 -0
  343. chrys/app/tui/util/context_pressure.py +146 -0
  344. chrys/app/tui/util/debug_log.py +292 -0
  345. chrys/app/tui/util/diff_entries.py +64 -0
  346. chrys/app/tui/util/formatting.py +53 -0
  347. chrys/app/tui/util/git_branch.py +326 -0
  348. chrys/app/tui/util/invocation_progress.py +57 -0
  349. chrys/app/tui/util/logo.py +32 -0
  350. chrys/app/tui/util/message_gate.py +35 -0
  351. chrys/app/tui/util/mutation_sources.py +14 -0
  352. chrys/app/tui/util/picker_text.py +27 -0
  353. chrys/app/tui/util/removal.py +47 -0
  354. chrys/app/tui/util/rich_style.py +36 -0
  355. chrys/app/tui/util/source_text.py +12 -0
  356. chrys/app/tui/util/static_update.py +88 -0
  357. chrys/app/tui/util/todo_format.py +51 -0
  358. chrys/app/tui/util/visibility.py +214 -0
  359. chrys/app/tui/widgets/__init__.py +60 -0
  360. chrys/app/tui/widgets/ask_user_controls.py +652 -0
  361. chrys/app/tui/widgets/ask_user_markdown.py +17 -0
  362. chrys/app/tui/widgets/ask_user_prompt.py +606 -0
  363. chrys/app/tui/widgets/buttons.py +52 -0
  364. chrys/app/tui/widgets/chat/__init__.py +3 -0
  365. chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
  366. chrys/app/tui/widgets/chat/chrome.py +296 -0
  367. chrys/app/tui/widgets/chat/compaction_card.py +353 -0
  368. chrys/app/tui/widgets/chat/context_fold.py +77 -0
  369. chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
  370. chrys/app/tui/widgets/chat/image_preview.py +214 -0
  371. chrys/app/tui/widgets/chat/live_renderer.py +383 -0
  372. chrys/app/tui/widgets/chat/messages.py +1039 -0
  373. chrys/app/tui/widgets/chat/panel.py +1769 -0
  374. chrys/app/tui/widgets/chat/ports.py +245 -0
  375. chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
  376. chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
  377. chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
  378. chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
  379. chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
  380. chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
  381. chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
  382. chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
  383. chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
  384. chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
  385. chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
  386. chrys/app/tui/widgets/chat/renderers/search.py +348 -0
  387. chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
  388. chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
  389. chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
  390. chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
  391. chrys/app/tui/widgets/chat/renderers/web.py +69 -0
  392. chrys/app/tui/widgets/chat/replay.py +1605 -0
  393. chrys/app/tui/widgets/chat/replay_mount.py +322 -0
  394. chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
  395. chrys/app/tui/widgets/chat/selection_controller.py +435 -0
  396. chrys/app/tui/widgets/chat/session_json.py +508 -0
  397. chrys/app/tui/widgets/chat/toc_model.py +156 -0
  398. chrys/app/tui/widgets/chat/tool_call.py +2478 -0
  399. chrys/app/tui/widgets/chat/tool_registry.py +753 -0
  400. chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
  401. chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
  402. chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
  403. chrys/app/tui/widgets/checkbox.py +24 -0
  404. chrys/app/tui/widgets/chrome/__init__.py +3 -0
  405. chrys/app/tui/widgets/chrome/app_header.py +283 -0
  406. chrys/app/tui/widgets/chrome/commands.py +95 -0
  407. chrys/app/tui/widgets/chrome/file_index.py +68 -0
  408. chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
  409. chrys/app/tui/widgets/chrome/footer.py +284 -0
  410. chrys/app/tui/widgets/chrome/image_paste.py +351 -0
  411. chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
  412. chrys/app/tui/widgets/chrome/status_bar.py +911 -0
  413. chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
  414. chrys/app/tui/widgets/click_affordance.py +24 -0
  415. chrys/app/tui/widgets/color_picker/__init__.py +254 -0
  416. chrys/app/tui/widgets/color_picker/controls.py +171 -0
  417. chrys/app/tui/widgets/color_picker/model.py +89 -0
  418. chrys/app/tui/widgets/dialog_buttons.py +58 -0
  419. chrys/app/tui/widgets/dialog_scroll.py +24 -0
  420. chrys/app/tui/widgets/diff_view/__init__.py +20 -0
  421. chrys/app/tui/widgets/diff_view/cells.py +59 -0
  422. chrys/app/tui/widgets/diff_view/code.py +280 -0
  423. chrys/app/tui/widgets/diff_view/compute.py +286 -0
  424. chrys/app/tui/widgets/diff_view/gutter.py +161 -0
  425. chrys/app/tui/widgets/diff_view/inline.py +77 -0
  426. chrys/app/tui/widgets/diff_view/palette.py +87 -0
  427. chrys/app/tui/widgets/diff_view/protocols.py +24 -0
  428. chrys/app/tui/widgets/diff_view/rows.py +138 -0
  429. chrys/app/tui/widgets/diff_view/unified.py +267 -0
  430. chrys/app/tui/widgets/diff_view/widget.py +209 -0
  431. chrys/app/tui/widgets/editor/__init__.py +79 -0
  432. chrys/app/tui/widgets/editor/coordinates.py +30 -0
  433. chrys/app/tui/widgets/editor/highlighter.py +139 -0
  434. chrys/app/tui/widgets/editor/keymaps.py +357 -0
  435. chrys/app/tui/widgets/editor/types.py +216 -0
  436. chrys/app/tui/widgets/editor/vim.py +1195 -0
  437. chrys/app/tui/widgets/editor/widget.py +401 -0
  438. chrys/app/tui/widgets/file_preview.py +58 -0
  439. chrys/app/tui/widgets/hatch.py +138 -0
  440. chrys/app/tui/widgets/input.py +66 -0
  441. chrys/app/tui/widgets/loading.py +125 -0
  442. chrys/app/tui/widgets/markdown/__init__.py +25 -0
  443. chrys/app/tui/widgets/markdown/_utils.py +21 -0
  444. chrys/app/tui/widgets/markdown/anchors.py +71 -0
  445. chrys/app/tui/widgets/markdown/blocks.py +115 -0
  446. chrys/app/tui/widgets/markdown/copyable.py +163 -0
  447. chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
  448. chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
  449. chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
  450. chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
  451. chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
  452. chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
  453. chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
  454. chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
  455. chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
  456. chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
  457. chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
  458. chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
  459. chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
  460. chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
  461. chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
  462. chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
  463. chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
  464. chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
  465. chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
  466. chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
  467. chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
  468. chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
  469. chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
  470. chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
  471. chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
  472. chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
  473. chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
  474. chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
  475. chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
  476. chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
  477. chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
  478. chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
  479. chrys/app/tui/widgets/markdown/links.py +47 -0
  480. chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
  481. chrys/app/tui/widgets/markdown/math/layout.py +739 -0
  482. chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
  483. chrys/app/tui/widgets/markdown/math/parser.py +325 -0
  484. chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
  485. chrys/app/tui/widgets/markdown/navigator.py +80 -0
  486. chrys/app/tui/widgets/markdown/parser.py +1057 -0
  487. chrys/app/tui/widgets/markdown/stream.py +73 -0
  488. chrys/app/tui/widgets/markdown/toc.py +98 -0
  489. chrys/app/tui/widgets/markdown/viewer.py +146 -0
  490. chrys/app/tui/widgets/markdown/widget.py +2046 -0
  491. chrys/app/tui/widgets/marquee.py +101 -0
  492. chrys/app/tui/widgets/option_menu.py +82 -0
  493. chrys/app/tui/widgets/page_navigator.py +118 -0
  494. chrys/app/tui/widgets/select.py +196 -0
  495. chrys/app/tui/widgets/selection.py +59 -0
  496. chrys/app/tui/widgets/sidebar/__init__.py +3 -0
  497. chrys/app/tui/widgets/sidebar/buddy.py +367 -0
  498. chrys/app/tui/widgets/sidebar/context.py +482 -0
  499. chrys/app/tui/widgets/sidebar/debug.py +195 -0
  500. chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
  501. chrys/app/tui/widgets/sidebar/panel.py +198 -0
  502. chrys/app/tui/widgets/sidebar/tasks.py +198 -0
  503. chrys/app/tui/widgets/sidebar/toc.py +166 -0
  504. chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
  505. chrys/app/tui/widgets/syntax_theme.py +85 -0
  506. chrys/app/tui/widgets/text_area.py +258 -0
  507. chrys/app/tui/widgets/trajectory/__init__.py +8 -0
  508. chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
  509. chrys/app/tui/widgets/trajectory/insights.py +1266 -0
  510. chrys/app/tui/widgets/trajectory/overview.py +1101 -0
  511. chrys/app/tui/widgets/trajectory/panel.py +983 -0
  512. chrys/app/tui/widgets/trajectory/presentation.py +311 -0
  513. chrys/app/tui/widgets/trajectory/session_info.py +124 -0
  514. chrys/app/tui/widgets/trajectory/text_view.py +124 -0
  515. chrys/app/tui/widgets/trajectory/timeline.py +380 -0
  516. chrys/app/tui/widgets/welcome.py +126 -0
  517. chrys/app/tui/widgets/workflow/__init__.py +5 -0
  518. chrys/app/tui/widgets/workflow/edges.py +89 -0
  519. chrys/app/tui/widgets/workflow/graph.py +869 -0
  520. chrys/app/tui/widgets/workflow/info.py +310 -0
  521. chrys/app/tui/widgets/workflow/node_view.py +27 -0
  522. chrys/app/tui/widgets/workflow/output.py +225 -0
  523. chrys/app/tui/widgets/workflow/panel.py +658 -0
  524. chrys/app/tui/widgets/workflow/projector.py +324 -0
  525. chrys/app/tui/widgets/workflow/records.py +69 -0
  526. chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
  527. chrys/app/tui/widgets/workflow/selection.py +151 -0
  528. chrys/app/tui/widgets/workflow/source.py +65 -0
  529. chrys/app/tui/widgets/workflow/text.py +257 -0
  530. chrys/app/tui/widgets/workflow/values.py +317 -0
  531. chrys/foundation/__init__.py +0 -0
  532. chrys/foundation/branding.py +15 -0
  533. chrys/foundation/config/__init__.py +7 -0
  534. chrys/foundation/config/coercion.py +374 -0
  535. chrys/foundation/config/context.py +34 -0
  536. chrys/foundation/config/env_file.py +141 -0
  537. chrys/foundation/config/env_layers.py +370 -0
  538. chrys/foundation/config/migrations.py +521 -0
  539. chrys/foundation/config/notification_events.py +22 -0
  540. chrys/foundation/config/process_settings.py +332 -0
  541. chrys/foundation/config/runtime_pointer.py +133 -0
  542. chrys/foundation/config/settings.py +1952 -0
  543. chrys/foundation/config/settings_store.py +996 -0
  544. chrys/foundation/config/spec.py +396 -0
  545. chrys/foundation/config/user_settings.py +140 -0
  546. chrys/foundation/config/warnings.py +262 -0
  547. chrys/foundation/config/web_values.py +135 -0
  548. chrys/foundation/config/yaml_store.py +487 -0
  549. chrys/foundation/errors/__init__.py +68 -0
  550. chrys/foundation/errors/_legacy.py +117 -0
  551. chrys/foundation/errors/_text.py +13 -0
  552. chrys/foundation/errors/_walk.py +85 -0
  553. chrys/foundation/errors/classify.py +278 -0
  554. chrys/foundation/errors/display.py +274 -0
  555. chrys/foundation/errors/formatting.py +283 -0
  556. chrys/foundation/errors/kinds.py +67 -0
  557. chrys/foundation/errors/network.py +497 -0
  558. chrys/foundation/errors/provider.py +445 -0
  559. chrys/foundation/errors/route.py +85 -0
  560. chrys/foundation/events/__init__.py +8 -0
  561. chrys/foundation/events/bus.py +143 -0
  562. chrys/foundation/events/types.py +1629 -0
  563. chrys/foundation/events/workflow.py +38 -0
  564. chrys/foundation/hosted_tools.py +142 -0
  565. chrys/foundation/i18n/__init__.py +32 -0
  566. chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
  567. chrys/foundation/i18n/catalogs.py +42 -0
  568. chrys/foundation/i18n/formatting.py +202 -0
  569. chrys/foundation/i18n/locale.py +92 -0
  570. chrys/foundation/i18n/localizer.py +187 -0
  571. chrys/foundation/i18n/messages.py +268 -0
  572. chrys/foundation/io/__init__.py +3 -0
  573. chrys/foundation/io/result_content.py +237 -0
  574. chrys/foundation/models/__init__.py +3 -0
  575. chrys/foundation/models/approval_reuse.py +23 -0
  576. chrys/foundation/models/ask_user.py +468 -0
  577. chrys/foundation/models/execution.py +18 -0
  578. chrys/foundation/models/history_markers.py +115 -0
  579. chrys/foundation/models/invocations.py +63 -0
  580. chrys/foundation/models/mutation_scope.py +28 -0
  581. chrys/foundation/models/session_env.py +87 -0
  582. chrys/foundation/models/session_surface.py +29 -0
  583. chrys/foundation/models/todos.py +74 -0
  584. chrys/foundation/models/turns.py +125 -0
  585. chrys/foundation/models/workflow_session.py +89 -0
  586. chrys/foundation/models/workspace.py +59 -0
  587. chrys/foundation/net/__init__.py +3 -0
  588. chrys/foundation/net/address_class.py +57 -0
  589. chrys/foundation/net/route_probe.py +94 -0
  590. chrys/foundation/net/url.py +107 -0
  591. chrys/foundation/observability/__init__.py +3 -0
  592. chrys/foundation/observability/exporters.py +174 -0
  593. chrys/foundation/observability/gate.py +31 -0
  594. chrys/foundation/observability/setup.py +497 -0
  595. chrys/foundation/observability/sink.py +228 -0
  596. chrys/foundation/patches/__init__.py +19 -0
  597. chrys/foundation/patches/mdurl_cache.py +59 -0
  598. chrys/foundation/patches/patcher.py +287 -0
  599. chrys/foundation/patches/staged_members.py +205 -0
  600. chrys/foundation/patches/textual_block_border.py +105 -0
  601. chrys/foundation/patches/textual_button_ansi.py +65 -0
  602. chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
  603. chrys/foundation/patches/textual_callback_cache.py +248 -0
  604. chrys/foundation/patches/textual_callback_dispatch.py +109 -0
  605. chrys/foundation/patches/textual_compositor_cjk.py +566 -0
  606. chrys/foundation/patches/textual_dispatch_cache.py +150 -0
  607. chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
  608. chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
  609. chrys/foundation/patches/textual_lru_acyclic.py +241 -0
  610. chrys/foundation/patches/textual_message_pump.py +136 -0
  611. chrys/foundation/patches/textual_node_diet.py +875 -0
  612. chrys/foundation/patches/textual_one_shot_timer.py +48 -0
  613. chrys/foundation/patches/textual_option_list.py +90 -0
  614. chrys/foundation/patches/textual_precompose.py +89 -0
  615. chrys/foundation/patches/textual_pruned_tabs.py +69 -0
  616. chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
  617. chrys/foundation/patches/textual_removed_node_caches.py +86 -0
  618. chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
  619. chrys/foundation/patches/textual_selection_extract.py +48 -0
  620. chrys/foundation/patches/textual_strip_cycles.py +119 -0
  621. chrys/foundation/patches/textual_tab_selection.py +1611 -0
  622. chrys/foundation/patches/textual_timer_skip.py +70 -0
  623. chrys/foundation/patches/textual_utf8_decoder.py +106 -0
  624. chrys/foundation/patches/textual_win_sleep.py +167 -0
  625. chrys/foundation/patches/watchdog_fsevents.py +70 -0
  626. chrys/foundation/patches/watchdog_windows.py +52 -0
  627. chrys/foundation/platform/__init__.py +556 -0
  628. chrys/foundation/platform/c_api.py +32 -0
  629. chrys/foundation/platform/child_reap.py +107 -0
  630. chrys/foundation/platform/command_line.py +95 -0
  631. chrys/foundation/platform/files.py +1470 -0
  632. chrys/foundation/platform/output_capture.py +113 -0
  633. chrys/foundation/platform/paths.py +193 -0
  634. chrys/foundation/platform/process.py +1468 -0
  635. chrys/foundation/platform/pty_output.py +19 -0
  636. chrys/foundation/platform/runtime_paths.py +281 -0
  637. chrys/foundation/platform/win_console.py +129 -0
  638. chrys/foundation/reasoning_origin.py +67 -0
  639. chrys/foundation/recovery.py +16 -0
  640. chrys/foundation/retry.py +278 -0
  641. chrys/foundation/text/__init__.py +3 -0
  642. chrys/foundation/text/encoding.py +603 -0
  643. chrys/foundation/text/images.py +304 -0
  644. chrys/foundation/text/lines.py +24 -0
  645. chrys/foundation/text/mentions.py +90 -0
  646. chrys/foundation/text/model_json.py +25 -0
  647. chrys/foundation/text/reading_order.py +20 -0
  648. chrys/foundation/text/tokenizer.py +25 -0
  649. chrys/foundation/text/tool_output.py +287 -0
  650. chrys/foundation/text/yaml_io.py +62 -0
  651. chrys/foundation/tool_call_context.py +202 -0
  652. chrys/foundation/tool_execution_stamp.py +184 -0
  653. chrys/foundation/tool_invocation_order.py +48 -0
  654. chrys/foundation/tool_kinds.py +102 -0
  655. chrys/foundation/tool_result_metadata.py +287 -0
  656. chrys/foundation/trajectory/__init__.py +10 -0
  657. chrys/foundation/trajectory/context.py +481 -0
  658. chrys/foundation/trajectory/envelope.py +571 -0
  659. chrys/foundation/trajectory/event_types.py +294 -0
  660. chrys/foundation/trajectory/finalizers.py +46 -0
  661. chrys/foundation/trajectory/fingerprint.py +62 -0
  662. chrys/foundation/trajectory/ids.py +155 -0
  663. chrys/foundation/trajectory/keys.py +99 -0
  664. chrys/foundation/trajectory/lease.py +173 -0
  665. chrys/foundation/trajectory/metadata.py +72 -0
  666. chrys/foundation/trajectory/reader.py +271 -0
  667. chrys/foundation/trajectory/recovery.py +225 -0
  668. chrys/foundation/trajectory/revisions.py +152 -0
  669. chrys/foundation/trajectory/segments.py +225 -0
  670. chrys/foundation/trajectory/tools.py +117 -0
  671. chrys/foundation/trajectory/usage.py +151 -0
  672. chrys/foundation/trajectory/writer.py +972 -0
  673. chrys/foundation/trajectory_timing.py +97 -0
  674. chrys/foundation/util/__init__.py +3 -0
  675. chrys/foundation/util/async_tasks.py +29 -0
  676. chrys/foundation/util/chrys_headers.py +23 -0
  677. chrys/foundation/util/env_templates.py +104 -0
  678. chrys/foundation/util/filenames.py +96 -0
  679. chrys/foundation/util/header_charset.py +138 -0
  680. chrys/foundation/util/httpx_helpers.py +12 -0
  681. chrys/foundation/util/lock.py +236 -0
  682. chrys/foundation/util/once_close.py +46 -0
  683. chrys/foundation/util/session_ids.py +29 -0
  684. chrys/foundation/util/shell_tokens.py +88 -0
  685. chrys/foundation/util/sub_agent_context.py +38 -0
  686. chrys/foundation/util/time.py +35 -0
  687. chrys/foundation/util/unicode_scalars.py +36 -0
  688. chrys/foundation/vendor/__init__.py +43 -0
  689. chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
  690. chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
  691. chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
  692. chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
  693. chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
  694. chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
  695. chrys/kernel/__init__.py +256 -0
  696. chrys/kernel/_content.py +1866 -0
  697. chrys/kernel/_loop_recorder.py +631 -0
  698. chrys/kernel/_null_overlay.py +1026 -0
  699. chrys/kernel/_result_ceiling.py +216 -0
  700. chrys/kernel/_serialization.py +431 -0
  701. chrys/kernel/_tool_arg_errors.py +1629 -0
  702. chrys/kernel/_tool_execution.py +1172 -0
  703. chrys/kernel/_tool_expansion.py +26 -0
  704. chrys/kernel/_types.py +2598 -0
  705. chrys/kernel/agent.py +994 -0
  706. chrys/kernel/client.py +1084 -0
  707. chrys/kernel/compaction.py +1315 -0
  708. chrys/kernel/exceptions.py +211 -0
  709. chrys/kernel/exchanges.py +683 -0
  710. chrys/kernel/identity.py +95 -0
  711. chrys/kernel/images.py +36 -0
  712. chrys/kernel/instrumentation.py +1618 -0
  713. chrys/kernel/loop.py +2308 -0
  714. chrys/kernel/middleware.py +616 -0
  715. chrys/kernel/sessions.py +604 -0
  716. chrys/kernel/tools.py +1061 -0
  717. chrys/kernel/types.py +126 -0
  718. chrys/orchestration/__init__.py +3 -0
  719. chrys/orchestration/engine/__init__.py +3 -0
  720. chrys/orchestration/engine/assembly.py +236 -0
  721. chrys/orchestration/engine/build/__init__.py +3 -0
  722. chrys/orchestration/engine/build/builder.py +1061 -0
  723. chrys/orchestration/engine/build/construction.py +408 -0
  724. chrys/orchestration/engine/build/loaded.py +139 -0
  725. chrys/orchestration/engine/engine.py +789 -0
  726. chrys/orchestration/engine/execution.py +771 -0
  727. chrys/orchestration/engine/loader.py +865 -0
  728. chrys/orchestration/engine/rollback.py +918 -0
  729. chrys/orchestration/engine/run/__init__.py +3 -0
  730. chrys/orchestration/engine/run/active_injection.py +419 -0
  731. chrys/orchestration/engine/run/attachments.py +519 -0
  732. chrys/orchestration/engine/run/bindings.py +1095 -0
  733. chrys/orchestration/engine/run/coordinator.py +1062 -0
  734. chrys/orchestration/engine/run/finalizer.py +417 -0
  735. chrys/orchestration/engine/run/input_refs.py +60 -0
  736. chrys/orchestration/engine/run/prompt_content.py +457 -0
  737. chrys/orchestration/engine/run/resume.py +299 -0
  738. chrys/orchestration/engine/run/retry.py +948 -0
  739. chrys/orchestration/engine/run/runner.py +625 -0
  740. chrys/orchestration/engine/run/runtime_skills.py +146 -0
  741. chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
  742. chrys/orchestration/engine/run/turn_hooks.py +258 -0
  743. chrys/orchestration/engine/run/turn_state.py +149 -0
  744. chrys/orchestration/engine/run/working_dir.py +50 -0
  745. chrys/orchestration/engine/session_lifecycle.py +2013 -0
  746. chrys/orchestration/engine/state/__init__.py +3 -0
  747. chrys/orchestration/engine/state/active_session.py +183 -0
  748. chrys/orchestration/engine/state/controls.py +639 -0
  749. chrys/orchestration/engine/state/current_agent.py +26 -0
  750. chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
  751. chrys/orchestration/engine/state/machine.py +193 -0
  752. chrys/orchestration/engine/state/session_writer.py +434 -0
  753. chrys/orchestration/engine/trajectory.py +714 -0
  754. chrys/orchestration/engine/usage.py +118 -0
  755. chrys/orchestration/invoker/__init__.py +3 -0
  756. chrys/orchestration/invoker/acp.py +727 -0
  757. chrys/orchestration/invoker/acp_protocol.py +1115 -0
  758. chrys/orchestration/invoker/acp_spec.py +79 -0
  759. chrys/orchestration/invoker/attempts.py +994 -0
  760. chrys/orchestration/invoker/child_compaction.py +131 -0
  761. chrys/orchestration/invoker/child_history.py +99 -0
  762. chrys/orchestration/invoker/contracts.py +253 -0
  763. chrys/orchestration/invoker/evidence.py +94 -0
  764. chrys/orchestration/invoker/kernel.py +343 -0
  765. chrys/orchestration/invoker/origin.py +79 -0
  766. chrys/orchestration/invoker/resources.py +343 -0
  767. chrys/orchestration/invoker/runtime.py +299 -0
  768. chrys/orchestration/invoker/usage.py +80 -0
  769. chrys/orchestration/session_hooks.py +117 -0
  770. chrys/orchestration/session_host.py +1023 -0
  771. chrys/orchestration/session_resources.py +95 -0
  772. chrys/orchestration/session_usage.py +219 -0
  773. chrys/orchestration/startup.py +255 -0
  774. chrys/orchestration/sub_agents/__init__.py +3 -0
  775. chrys/orchestration/sub_agents/acp_policy.py +269 -0
  776. chrys/orchestration/sub_agents/kernel_policy.py +848 -0
  777. chrys/orchestration/sub_agents/registration.py +44 -0
  778. chrys/orchestration/sub_agents/shell.py +337 -0
  779. chrys/orchestration/sub_agents/tools.py +2228 -0
  780. chrys/orchestration/workflows/__init__.py +3 -0
  781. chrys/orchestration/workflows/agent_archive.py +156 -0
  782. chrys/orchestration/workflows/agent_node.py +894 -0
  783. chrys/orchestration/workflows/agent_node_build.py +687 -0
  784. chrys/orchestration/workflows/catalog.py +332 -0
  785. chrys/orchestration/workflows/coordinator.py +771 -0
  786. chrys/orchestration/workflows/hooks.py +176 -0
  787. chrys/orchestration/workflows/preview.py +322 -0
  788. chrys/orchestration/workflows/rollback.py +151 -0
  789. chrys/orchestration/workflows/runner.py +986 -0
  790. chrys/orchestration/workflows/session.py +390 -0
  791. chrys/orchestration/workflows/settings.py +97 -0
  792. chrys/orchestration/workflows/validation.py +674 -0
  793. chrys/orchestration/workflows/worker_client.py +1002 -0
  794. chrys/service/__init__.py +3 -0
  795. chrys/service/acp_client/__init__.py +65 -0
  796. chrys/service/acp_client/_transport.py +922 -0
  797. chrys/service/acp_client/client.py +1311 -0
  798. chrys/service/acp_client/errors.py +224 -0
  799. chrys/service/acp_client/protocol.py +220 -0
  800. chrys/service/acp_client/spawn.py +239 -0
  801. chrys/service/acp_client/spec.py +100 -0
  802. chrys/service/agent_middleware/__init__.py +60 -0
  803. chrys/service/agent_middleware/_metadata_keys.py +52 -0
  804. chrys/service/agent_middleware/control/__init__.py +3 -0
  805. chrys/service/agent_middleware/control/approval.py +769 -0
  806. chrys/service/agent_middleware/control/approval_hooks.py +62 -0
  807. chrys/service/agent_middleware/control/ask_user.py +207 -0
  808. chrys/service/agent_middleware/control/interrupt.py +50 -0
  809. chrys/service/agent_middleware/control/sleep.py +118 -0
  810. chrys/service/agent_middleware/control/todo.py +117 -0
  811. chrys/service/agent_middleware/events/__init__.py +3 -0
  812. chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
  813. chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
  814. chrys/service/agent_middleware/events/intermediate_text.py +128 -0
  815. chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
  816. chrys/service/agent_middleware/events/result_persistence.py +98 -0
  817. chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
  818. chrys/service/agent_middleware/events/tool_error_text.py +22 -0
  819. chrys/service/agent_middleware/events/tool_events.py +779 -0
  820. chrys/service/agent_middleware/injection.py +510 -0
  821. chrys/service/agent_middleware/reminders/__init__.py +91 -0
  822. chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
  823. chrys/service/agent_middleware/reminders/base.py +25 -0
  824. chrys/service/agent_middleware/reminders/context_usage.py +74 -0
  825. chrys/service/agent_middleware/reminders/file_change.py +63 -0
  826. chrys/service/agent_middleware/reminders/mcp.py +39 -0
  827. chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
  828. chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
  829. chrys/service/agent_middleware/reminders/skills.py +44 -0
  830. chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
  831. chrys/service/agent_middleware/reminders/todo.py +35 -0
  832. chrys/service/agent_middleware/reminders/turn_line.py +48 -0
  833. chrys/service/agent_middleware/response_validation.py +1380 -0
  834. chrys/service/agent_middleware/system_reminder.py +1107 -0
  835. chrys/service/agent_middleware/validators.py +396 -0
  836. chrys/service/analytics/__init__.py +124 -0
  837. chrys/service/analytics/_actions.py +896 -0
  838. chrys/service/analytics/_context_evidence.py +357 -0
  839. chrys/service/analytics/_critical_path.py +179 -0
  840. chrys/service/analytics/_facts.py +919 -0
  841. chrys/service/analytics/_insights.py +769 -0
  842. chrys/service/analytics/_metric_ops.py +83 -0
  843. chrys/service/analytics/_session_projection.py +460 -0
  844. chrys/service/analytics/_timeline.py +434 -0
  845. chrys/service/analytics/_turn_graph.py +1237 -0
  846. chrys/service/analytics/_turns.py +1377 -0
  847. chrys/service/analytics/_workflows.py +171 -0
  848. chrys/service/analytics/aggregation.py +1474 -0
  849. chrys/service/analytics/classification.py +128 -0
  850. chrys/service/analytics/export.py +803 -0
  851. chrys/service/analytics/findings.py +282 -0
  852. chrys/service/analytics/math.py +79 -0
  853. chrys/service/analytics/model.py +886 -0
  854. chrys/service/analytics/reader.py +321 -0
  855. chrys/service/approval/__init__.py +3 -0
  856. chrys/service/approval/arbitration.py +102 -0
  857. chrys/service/approval/command_identity.py +109 -0
  858. chrys/service/approval/correlation.py +57 -0
  859. chrys/service/approval/grant_store.py +108 -0
  860. chrys/service/approval/judge.py +589 -0
  861. chrys/service/approval/level.py +15 -0
  862. chrys/service/approval/policy.py +209 -0
  863. chrys/service/approval/reuse.py +223 -0
  864. chrys/service/approval/reuse_binding.py +133 -0
  865. chrys/service/approval/safety_classifier.py +536 -0
  866. chrys/service/approval/turn_context.py +49 -0
  867. chrys/service/context/__init__.py +3 -0
  868. chrys/service/context/compaction/__init__.py +38 -0
  869. chrys/service/context/compaction/breaker.py +156 -0
  870. chrys/service/context/compaction/budgets.py +93 -0
  871. chrys/service/context/compaction/calibration.py +77 -0
  872. chrys/service/context/compaction/compression.py +690 -0
  873. chrys/service/context/compaction/current_turn_drop.py +458 -0
  874. chrys/service/context/compaction/events.py +201 -0
  875. chrys/service/context/compaction/exclusions.py +261 -0
  876. chrys/service/context/compaction/groups.py +361 -0
  877. chrys/service/context/compaction/last_words.py +1996 -0
  878. chrys/service/context/compaction/last_words_state.py +752 -0
  879. chrys/service/context/compaction/scoped.py +472 -0
  880. chrys/service/context/compaction/spill.py +1947 -0
  881. chrys/service/context/compaction/strategy.py +1116 -0
  882. chrys/service/context/compaction/summaries.py +319 -0
  883. chrys/service/context/compaction/turns.py +375 -0
  884. chrys/service/context/manager.py +174 -0
  885. chrys/service/context/memory_loader.py +792 -0
  886. chrys/service/context/middleware/__init__.py +3 -0
  887. chrys/service/context/middleware/usage.py +332 -0
  888. chrys/service/context/providers/__init__.py +3 -0
  889. chrys/service/context/providers/context_management.py +432 -0
  890. chrys/service/context/providers/history.py +647 -0
  891. chrys/service/context/providers/memory.py +50 -0
  892. chrys/service/context/tool_content.py +22 -0
  893. chrys/service/hooks/__init__.py +37 -0
  894. chrys/service/hooks/detached_worker.py +118 -0
  895. chrys/service/hooks/docs/README.md +14 -0
  896. chrys/service/hooks/docs/authoring.md +684 -0
  897. chrys/service/hooks/docs/configuration.md +499 -0
  898. chrys/service/hooks/docs/design.md +359 -0
  899. chrys/service/hooks/docs/project-hooks.md +348 -0
  900. chrys/service/hooks/events.py +157 -0
  901. chrys/service/hooks/loader.py +665 -0
  902. chrys/service/hooks/manager.py +807 -0
  903. chrys/service/hooks/matcher.py +111 -0
  904. chrys/service/hooks/outbox.py +294 -0
  905. chrys/service/hooks/runner.py +572 -0
  906. chrys/service/hooks/schema.py +407 -0
  907. chrys/service/llm/__init__.py +7 -0
  908. chrys/service/llm/_structured_outputs.py +192 -0
  909. chrys/service/llm/anthropic_messages/__init__.py +9 -0
  910. chrys/service/llm/anthropic_messages/client.py +254 -0
  911. chrys/service/llm/anthropic_messages/decode.py +331 -0
  912. chrys/service/llm/anthropic_messages/history.py +279 -0
  913. chrys/service/llm/anthropic_messages/request.py +323 -0
  914. chrys/service/llm/anthropic_messages/server_tools.py +319 -0
  915. chrys/service/llm/anthropic_messages/stream.py +297 -0
  916. chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
  917. chrys/service/llm/chat_completions/__init__.py +9 -0
  918. chrys/service/llm/chat_completions/client.py +369 -0
  919. chrys/service/llm/chat_completions/decode.py +338 -0
  920. chrys/service/llm/chat_completions/history.py +657 -0
  921. chrys/service/llm/chat_completions/reasoning.py +244 -0
  922. chrys/service/llm/chat_completions/request.py +192 -0
  923. chrys/service/llm/chat_completions/stream.py +546 -0
  924. chrys/service/llm/chat_completions/validation.py +308 -0
  925. chrys/service/llm/clients.py +613 -0
  926. chrys/service/llm/images.py +65 -0
  927. chrys/service/llm/mock.py +413 -0
  928. chrys/service/llm/observer.py +446 -0
  929. chrys/service/llm/one_shot.py +106 -0
  930. chrys/service/llm/openai_exceptions.py +93 -0
  931. chrys/service/llm/openai_responses/__init__.py +10 -0
  932. chrys/service/llm/openai_responses/client.py +323 -0
  933. chrys/service/llm/openai_responses/decode.py +432 -0
  934. chrys/service/llm/openai_responses/history.py +519 -0
  935. chrys/service/llm/openai_responses/hosted.py +625 -0
  936. chrys/service/llm/openai_responses/replay.py +629 -0
  937. chrys/service/llm/openai_responses/request.py +342 -0
  938. chrys/service/llm/openai_responses/stream.py +847 -0
  939. chrys/service/llm/openai_timestamps.py +57 -0
  940. chrys/service/llm/providers.py +107 -0
  941. chrys/service/llm/proxy_route.py +54 -0
  942. chrys/service/llm/raw_http_log.py +309 -0
  943. chrys/service/llm/route_facts.py +63 -0
  944. chrys/service/llm/route_sessions.py +49 -0
  945. chrys/service/llm/sdk_anthropic.py +18 -0
  946. chrys/service/llm/sdk_headers.py +47 -0
  947. chrys/service/llm/sdk_openai.py +18 -0
  948. chrys/service/llm/sdk_retry.py +59 -0
  949. chrys/service/llm/wire_client.py +246 -0
  950. chrys/service/mcp/__init__.py +3 -0
  951. chrys/service/mcp/_connection.py +235 -0
  952. chrys/service/mcp/_http_transport.py +350 -0
  953. chrys/service/mcp/_progressive.py +459 -0
  954. chrys/service/mcp/_stdio_transport.py +614 -0
  955. chrys/service/mcp/_tool_mixins.py +96 -0
  956. chrys/service/mcp/adapter.py +1031 -0
  957. chrys/service/mcp/cache.py +476 -0
  958. chrys/service/mcp/content.py +32 -0
  959. chrys/service/mcp/errors.py +169 -0
  960. chrys/service/mcp/owned.py +1330 -0
  961. chrys/service/mcp/result_limits.py +168 -0
  962. chrys/service/mcp/thinking_warning.py +95 -0
  963. chrys/service/mcp/validation.py +74 -0
  964. chrys/service/mutations/__init__.py +84 -0
  965. chrys/service/mutations/coordination.py +894 -0
  966. chrys/service/mutations/detector.py +458 -0
  967. chrys/service/mutations/git_calibrator.py +397 -0
  968. chrys/service/mutations/git_state.py +647 -0
  969. chrys/service/mutations/pipeline.py +779 -0
  970. chrys/service/mutations/scanner.py +466 -0
  971. chrys/service/mutations/snapshot_files.py +233 -0
  972. chrys/service/mutations/store.py +625 -0
  973. chrys/service/mutations/tool_names.py +15 -0
  974. chrys/service/mutations/trace.py +246 -0
  975. chrys/service/mutations/tracker.py +1289 -0
  976. chrys/service/mutations/types.py +559 -0
  977. chrys/service/mutations/workspace_changes.py +1583 -0
  978. chrys/service/profiles/__init__.py +2 -0
  979. chrys/service/profiles/agents/__init__.py +3 -0
  980. chrys/service/profiles/agents/builtins/Code.yaml +199 -0
  981. chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
  982. chrys/service/profiles/agents/builtins/General.yaml +114 -0
  983. chrys/service/profiles/agents/builtins/QA.yaml +128 -0
  984. chrys/service/profiles/agents/loader.py +808 -0
  985. chrys/service/profiles/agents/registry.py +727 -0
  986. chrys/service/profiles/agents/schema.py +371 -0
  987. chrys/service/profiles/agents/serializer.py +196 -0
  988. chrys/service/profiles/coercion.py +22 -0
  989. chrys/service/profiles/models/__init__.py +3 -0
  990. chrys/service/profiles/models/env_bridge.py +157 -0
  991. chrys/service/profiles/models/loader.py +282 -0
  992. chrys/service/profiles/models/options.py +438 -0
  993. chrys/service/profiles/models/registry.py +104 -0
  994. chrys/service/profiles/models/resolver.py +286 -0
  995. chrys/service/profiles/models/schema.py +91 -0
  996. chrys/service/profiles/models/serializer.py +76 -0
  997. chrys/service/session/__init__.py +3 -0
  998. chrys/service/session/checkpoint.py +156 -0
  999. chrys/service/session/history.py +1616 -0
  1000. chrys/service/session/message_metadata.py +151 -0
  1001. chrys/service/session/persistence.py +435 -0
  1002. chrys/service/session/runtime_metadata.py +210 -0
  1003. chrys/service/session/sub_agent_logs.py +1069 -0
  1004. chrys/service/session/sub_agent_transcript.py +654 -0
  1005. chrys/service/skills/__init__.py +3 -0
  1006. chrys/service/skills/adapter.py +408 -0
  1007. chrys/service/skills/constants.py +8 -0
  1008. chrys/service/skills/loader.py +575 -0
  1009. chrys/service/skills/model.py +130 -0
  1010. chrys/service/skills/provider.py +841 -0
  1011. chrys/service/skills/runner.py +390 -0
  1012. chrys/service/state/__init__.py +3 -0
  1013. chrys/service/state/_fork.py +710 -0
  1014. chrys/service/state/_session_files.py +175 -0
  1015. chrys/service/state/_session_meta.py +552 -0
  1016. chrys/service/state/locks.py +154 -0
  1017. chrys/service/state/serializers.py +156 -0
  1018. chrys/service/state/session_catalog.py +361 -0
  1019. chrys/service/state/session_listing.py +84 -0
  1020. chrys/service/state/session_migration.py +446 -0
  1021. chrys/service/state/session_mru.py +315 -0
  1022. chrys/service/state/store.py +2132 -0
  1023. chrys/service/state/workflow.py +158 -0
  1024. chrys/service/text_blocks.py +47 -0
  1025. chrys/service/todos/__init__.py +3 -0
  1026. chrys/service/todos/reminder.py +31 -0
  1027. chrys/service/todos/tracker.py +58 -0
  1028. chrys/service/tools/__init__.py +3 -0
  1029. chrys/service/tools/approval_targets.py +92 -0
  1030. chrys/service/tools/builtins/__init__.py +3 -0
  1031. chrys/service/tools/builtins/ask_user.py +326 -0
  1032. chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
  1033. chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
  1034. chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
  1035. chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
  1036. chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
  1037. chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
  1038. chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
  1039. chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
  1040. chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
  1041. chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
  1042. chrys/service/tools/builtins/doc_converter/registry.py +55 -0
  1043. chrys/service/tools/builtins/filesystem.py +1071 -0
  1044. chrys/service/tools/builtins/search.py +1265 -0
  1045. chrys/service/tools/builtins/shell.py +967 -0
  1046. chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
  1047. chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
  1048. chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
  1049. chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
  1050. chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
  1051. chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
  1052. chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
  1053. chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
  1054. chrys/service/tools/builtins/shell_filter.py +24 -0
  1055. chrys/service/tools/builtins/sleep.py +91 -0
  1056. chrys/service/tools/builtins/todo.py +64 -0
  1057. chrys/service/tools/builtins/web/__init__.py +6 -0
  1058. chrys/service/tools/builtins/web/build.py +268 -0
  1059. chrys/service/tools/builtins/web/config.py +338 -0
  1060. chrys/service/tools/builtins/web/credentials.py +37 -0
  1061. chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
  1062. chrys/service/tools/builtins/web/fetch/cache.py +68 -0
  1063. chrys/service/tools/builtins/web/fetch/convert.py +56 -0
  1064. chrys/service/tools/builtins/web/fetch/tool.py +223 -0
  1065. chrys/service/tools/builtins/web/hosted.py +61 -0
  1066. chrys/service/tools/builtins/web/http.py +330 -0
  1067. chrys/service/tools/builtins/web/progress.py +18 -0
  1068. chrys/service/tools/builtins/web/search/__init__.py +3 -0
  1069. chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
  1070. chrys/service/tools/builtins/web/search/html.py +152 -0
  1071. chrys/service/tools/builtins/web/search/json_api.py +227 -0
  1072. chrys/service/tools/builtins/web/search/providers.py +77 -0
  1073. chrys/service/tools/builtins/web/search/tool.py +218 -0
  1074. chrys/service/tools/builtins/web/search/types.py +28 -0
  1075. chrys/service/tools/builtins/web/tls_identity.py +66 -0
  1076. chrys/service/tools/kinds.py +115 -0
  1077. chrys/service/tools/names.py +47 -0
  1078. chrys/service/tools/registry.py +242 -0
  1079. chrys/service/tools/result_metadata.py +103 -0
  1080. chrys/service/tools/session_artifacts.py +196 -0
  1081. chrys/service/tools/spill.py +218 -0
  1082. chrys/service/tools/workspace_paths.py +81 -0
  1083. chrys/service/trajectory/__init__.py +11 -0
  1084. chrys/service/trajectory/approvals.py +201 -0
  1085. chrys/service/trajectory/compaction.py +420 -0
  1086. chrys/service/trajectory/fork.py +75 -0
  1087. chrys/service/trajectory/hooks.py +217 -0
  1088. chrys/service/trajectory/items.py +41 -0
  1089. chrys/service/trajectory/preparation.py +374 -0
  1090. chrys/service/trajectory/retries.py +95 -0
  1091. chrys/service/trajectory/revisions.py +129 -0
  1092. chrys/service/trajectory/segmented.py +163 -0
  1093. chrys/service/trajectory/session.py +762 -0
  1094. chrys/service/trajectory/state.py +83 -0
  1095. chrys/service/trajectory/sub_agents.py +169 -0
  1096. chrys/service/trajectory/tombstone.py +262 -0
  1097. chrys/service/trajectory/tools.py +323 -0
  1098. chrys/service/trajectory/validation.py +130 -0
  1099. chrys/service/trajectory/waits.py +161 -0
  1100. chrys/service/trajectory/workflow.py +148 -0
  1101. chrys/service/vision.py +128 -0
  1102. chrys/service/workflows/__init__.py +3 -0
  1103. chrys/service/workflows/admission.py +151 -0
  1104. chrys/service/workflows/artifacts.py +109 -0
  1105. chrys/service/workflows/asks.py +130 -0
  1106. chrys/service/workflows/builtins/__init__.py +12 -0
  1107. chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
  1108. chrys/service/workflows/builtins/demo-workflow.py +756 -0
  1109. chrys/service/workflows/discovery.py +554 -0
  1110. chrys/service/workflows/environment.py +284 -0
  1111. chrys/service/workflows/graph.py +430 -0
  1112. chrys/service/workflows/history.py +82 -0
  1113. chrys/service/workflows/interpreter.py +107 -0
  1114. chrys/service/workflows/journal.py +334 -0
  1115. chrys/service/workflows/layout.py +56 -0
  1116. chrys/service/workflows/ledger.py +149 -0
  1117. chrys/service/workflows/model_selection.py +18 -0
  1118. chrys/service/workflows/orphans.py +209 -0
  1119. chrys/service/workflows/outcomes.py +46 -0
  1120. chrys/service/workflows/protocol.py +210 -0
  1121. chrys/service/workflows/records.py +142 -0
  1122. chrys/service/workflows/scheduler.py +770 -0
  1123. chrys/service/workflows/sdk/__init__.py +51 -0
  1124. chrys/service/workflows/sdk/_ask.py +167 -0
  1125. chrys/service/workflows/sdk/_builder.py +781 -0
  1126. chrys/service/workflows/sdk/_values.py +92 -0
  1127. chrys/service/workflows/sdk_artifact.py +74 -0
  1128. chrys/service/workflows/store.py +494 -0
  1129. chrys/service/workflows/transcript.py +102 -0
  1130. chrys/service/workflows/values.py +73 -0
  1131. chrys/service/workflows/worker_host.py +1820 -0
  1132. chrys/workflows.py +38 -0
  1133. icode_tui-0.29.1.dist-info/METADATA +104 -0
  1134. icode_tui-0.29.1.dist-info/RECORD +1138 -0
  1135. icode_tui-0.29.1.dist-info/WHEEL +4 -0
  1136. icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
  1137. icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
  1138. icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
@@ -0,0 +1,2046 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """VirtualizedMarkdown — high-performance markdown widget using the Line API.
4
+
5
+ Uses virtualization (only renders visible lines) for optimal performance
6
+ with large documents. Drop-in replacement for Textual's built-in Markdown
7
+ widget, but avoids mounting one widget per block.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from bisect import bisect_left, bisect_right
14
+ from collections import OrderedDict
15
+ from collections.abc import Callable, Coroutine, Sequence
16
+ from dataclasses import dataclass, replace
17
+ from functools import partial
18
+ from pathlib import Path
19
+ from typing import Any, ClassVar
20
+ from urllib.parse import unquote
21
+
22
+ from markdown_it import MarkdownIt
23
+ from rich.cells import set_cell_size
24
+ from rich.segment import Segment
25
+ from rich.style import Style as RichStyle
26
+ from textual._cells import cell_len
27
+ from textual.await_complete import AwaitComplete
28
+ from textual.cache import LRUCache
29
+ from textual.content import Content, Span
30
+ from textual.dom import NoScreen
31
+ from textual.events import Click, Leave, Mount, MouseMove, Resize
32
+ from textual.geometry import Offset, Region, Size
33
+ from textual.message import Message
34
+ from textual.scroll_view import ScrollView
35
+ from textual.selection import Selection
36
+ from textual.strip import Strip
37
+ from textual.style import Style
38
+ from textual.visual import RenderOptions
39
+ from textual.widget import Widget
40
+
41
+ from chrys.app.tui.i18n import render_str, widget_localizer
42
+ from chrys.app.tui.support.gc_freeze import detach_lru_cache, renew_lru_cache
43
+ from chrys.app.tui.widgets import normalize_selection_rich_style as _normalize_selection_rich_style
44
+ from chrys.app.tui.widgets.markdown._utils import sanitize_location as _sanitize_location
45
+ from chrys.app.tui.widgets.markdown.anchors import find_heading
46
+ from chrys.app.tui.widgets.markdown.blocks import MarkdownBlock, MarkdownGutter, TableOfContentsType, _BlockLineInfo
47
+ from chrys.app.tui.widgets.markdown.diagram import compile_mermaid
48
+ from chrys.app.tui.widgets.markdown.diagram.messages import DiagramOpenRequested
49
+ from chrys.app.tui.widgets.markdown.diagram.model import CompiledDiagram
50
+ from chrys.app.tui.widgets.markdown.links import external_link_target
51
+ from chrys.app.tui.widgets.markdown.math import CompiledMath, compile_math
52
+ from chrys.app.tui.widgets.markdown.math.markdown import MATH_ENABLED
53
+ from chrys.app.tui.widgets.markdown.math.parser import MAX_SOURCE as MAX_MATH_SOURCE
54
+ from chrys.app.tui.widgets.markdown.parser import (
55
+ _cell_min_width,
56
+ _cell_natural_width,
57
+ _cell_prefix_length,
58
+ _cell_wrapped_height,
59
+ _configure_markdown_parser,
60
+ _create_markdown_parser,
61
+ _parse_tokens,
62
+ _wrap_cell_text,
63
+ )
64
+ from chrys.app.tui.widgets.markdown.stream import MarkdownStream
65
+ from chrys.foundation.i18n import msg
66
+
67
+ _DIAGRAM_OPEN = msg("tui.markdown.diagram.open", fallback="Open full diagram")
68
+ _INLINE_DIAGRAM_MAX_HEIGHT = 24
69
+ _DIAGRAM_COMPILE_CACHE_SIZE = 24
70
+
71
+
72
+ def _strip_plain_text(strip: Strip) -> str:
73
+ """Extract plain text from a Strip by joining segment texts."""
74
+ return "".join(seg.text for seg in strip._segments)
75
+
76
+
77
+ def _line_ranges(text: str) -> list[tuple[int, int]]:
78
+ """Return newline-exclusive character ranges for every source line."""
79
+ if not text:
80
+ return [(0, 0)]
81
+
82
+ ranges: list[tuple[int, int]] = []
83
+ start = 0
84
+ while True:
85
+ newline = text.find("\n", start)
86
+ if newline == -1:
87
+ ranges.append((start, len(text)))
88
+ return ranges
89
+ ranges.append((start, newline))
90
+ start = newline + 1
91
+ if start == len(text):
92
+ ranges.append((start, start))
93
+ return ranges
94
+
95
+
96
+ def _content_slice(content: Content, start: int, end: int, span_indices: list[int] | None = None) -> Content:
97
+ """Return a slice of ``content`` while preserving overlapping spans."""
98
+ text = content.plain[start:end]
99
+ if not content.spans:
100
+ return Content(text, strip_control_codes=False)
101
+
102
+ spans: list[Span] = []
103
+ source_spans = content.spans
104
+ indices = span_indices if span_indices is not None else range(len(source_spans))
105
+ for span_index in indices:
106
+ span_start, span_end, style = source_spans[span_index]
107
+ if span_end <= start:
108
+ continue
109
+ if span_start >= end:
110
+ continue
111
+ slice_start = max(span_start, start) - start
112
+ slice_end = min(span_end, end) - start
113
+ if slice_end > slice_start:
114
+ spans.append(Span(slice_start, slice_end, style))
115
+ return Content(text, spans, strip_control_codes=False)
116
+
117
+
118
+ def _span_indices_by_line(spans: Sequence[Span], line_ranges: list[tuple[int, int]]) -> list[list[int]] | None:
119
+ """Return candidate span indices for each source line."""
120
+ if not spans:
121
+ return None
122
+
123
+ line_starts = [start for start, _end in line_ranges]
124
+ line_ends = [end for _start, end in line_ranges]
125
+ indices_by_line: list[list[int]] = [[] for _ in line_ranges]
126
+ for span_index, span in enumerate(spans):
127
+ first_line = bisect_right(line_ends, span.start)
128
+ last_line = bisect_left(line_starts, span.end)
129
+ for line_index in range(first_line, last_line):
130
+ indices_by_line[line_index].append(span_index)
131
+ return indices_by_line
132
+
133
+
134
+ def _shrink_widest_columns(natural: list[int], floors: list[int], available: int) -> list[int]:
135
+ """Fit column widths into exactly *available* cells, shrinking the widest columns first.
136
+
137
+ Every column is capped at one shared width, the largest that fits, so a short column
138
+ keeps its natural width while the long ones wrap; the cells the cap leaves over go to
139
+ the capped columns one each. Shrinking every column in proportion to its natural
140
+ width instead squeezed a three-glyph "Default" column down to two glyphs a line next
141
+ to a long description. Requires ``sum(floors) <= available < sum(natural)``.
142
+ """
143
+
144
+ def total_at(cap: int) -> int:
145
+ return sum(max(floor, min(width, cap)) for width, floor in zip(natural, floors, strict=True))
146
+
147
+ low, high = 1, max(natural)
148
+ while low < high:
149
+ middle = (low + high + 1) // 2
150
+ if total_at(middle) <= available:
151
+ low = middle
152
+ else:
153
+ high = middle - 1
154
+ widths = [max(floor, min(width, low)) for width, floor in zip(natural, floors, strict=True)]
155
+ spare = available - sum(widths)
156
+ for index, width in enumerate(natural):
157
+ if spare <= 0:
158
+ break
159
+ if widths[index] < width:
160
+ widths[index] += 1
161
+ spare -= 1
162
+ return widths
163
+
164
+
165
+ @dataclass
166
+ class _TableLayout:
167
+ """Width and height metadata for a table block."""
168
+
169
+ col_widths: list[int]
170
+ header_height: int
171
+ row_heights: list[int]
172
+ row_starts: list[int]
173
+ total_height: int
174
+
175
+
176
+ @dataclass
177
+ class _FenceLayout:
178
+ """Source-line layout metadata for a fenced code block."""
179
+
180
+ line_ranges: list[tuple[int, int]]
181
+ line_span_indices: list[list[int]] | None
182
+ line_starts: list[int]
183
+ total_height: int
184
+
185
+
186
+ @dataclass(frozen=True, slots=True)
187
+ class _DiagramLayout:
188
+ """Viewport-independent diagram metadata for one Markdown layout pass."""
189
+
190
+ preview_height: int
191
+
192
+ @property
193
+ def total_height(self) -> int:
194
+ return self.preview_height + 1
195
+
196
+
197
+ class _PendingParse:
198
+ """The parse that ``update()``/``append()`` scheduled and that has not read the source yet.
199
+
200
+ Requests made before it takes the widget's lock join it rather than
201
+ scheduling a parse of their own: it parses whatever source is current once
202
+ it holds the lock, so it installs text at least as new as every joined
203
+ request's.
204
+ """
205
+
206
+ __slots__ = ("announce_contents", "task")
207
+
208
+ def __init__(
209
+ self,
210
+ run: Callable[[_PendingParse], Coroutine[Any, Any, None]],
211
+ *,
212
+ announce_contents: bool,
213
+ ) -> None:
214
+ loop = asyncio.get_running_loop()
215
+ self.announce_contents = announce_contents
216
+ """Post ``TableOfContentsUpdated`` even without headings: an ``update()`` joined."""
217
+ self.task: asyncio.Task[None] = loop.create_task(run(self))
218
+ """The parse; awaiters get it shielded, so no single caller can cancel it for the others."""
219
+
220
+
221
+ class VirtualizedMarkdown(ScrollView, can_focus=True):
222
+ """A Markdown widget that uses virtualization for efficient rendering.
223
+
224
+ This widget parses markdown and renders it using the Line API,
225
+ only rendering visible lines for optimal performance with large documents.
226
+ """
227
+
228
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
229
+ "virtualized-markdown--h1",
230
+ "virtualized-markdown--h2",
231
+ "virtualized-markdown--h3",
232
+ "virtualized-markdown--h4",
233
+ "virtualized-markdown--h5",
234
+ "virtualized-markdown--h6",
235
+ "virtualized-markdown--paragraph",
236
+ "virtualized-markdown--fence",
237
+ "virtualized-markdown--diagram",
238
+ "virtualized-markdown--diagram-action",
239
+ "virtualized-markdown--hr",
240
+ "virtualized-markdown--table",
241
+ "virtualized-markdown--table-header",
242
+ "virtualized-markdown--block-quote",
243
+ "virtualized-markdown--block-quote-border",
244
+ "virtualized-markdown--bullet",
245
+ "code_inline",
246
+ "em",
247
+ "strong",
248
+ "s",
249
+ }
250
+
251
+ DEFAULT_CSS = """
252
+ VirtualizedMarkdown {
253
+ color: $foreground;
254
+ overflow-y: auto;
255
+ overflow-x: hidden;
256
+ background: $surface;
257
+ padding: 0 2 0 2;
258
+
259
+ & > .virtualized-markdown--h1 {
260
+ color: $markdown-h1-color;
261
+ background: $markdown-h1-background;
262
+ text-style: $markdown-h1-text-style;
263
+ content-align: center middle;
264
+ }
265
+ & > .virtualized-markdown--h2 {
266
+ color: $markdown-h2-color;
267
+ background: $markdown-h2-background;
268
+ text-style: $markdown-h2-text-style;
269
+ }
270
+ & > .virtualized-markdown--h3 {
271
+ color: $markdown-h3-color;
272
+ background: $markdown-h3-background;
273
+ text-style: $markdown-h3-text-style;
274
+ }
275
+ & > .virtualized-markdown--h4 {
276
+ color: $markdown-h4-color;
277
+ background: $markdown-h4-background;
278
+ text-style: $markdown-h4-text-style;
279
+ }
280
+ & > .virtualized-markdown--h5 {
281
+ color: $markdown-h5-color;
282
+ background: $markdown-h5-background;
283
+ text-style: $markdown-h5-text-style;
284
+ }
285
+ & > .virtualized-markdown--h6 {
286
+ color: $markdown-h6-color;
287
+ background: $markdown-h6-background;
288
+ text-style: $markdown-h6-text-style;
289
+ }
290
+ & > .virtualized-markdown--fence {
291
+ background: black 10%;
292
+ color: $text;
293
+ }
294
+ &:light > .virtualized-markdown--fence {
295
+ background: white 30%;
296
+ }
297
+ & > .virtualized-markdown--diagram {
298
+ background: $surface-lighten-1;
299
+ color: $foreground;
300
+ }
301
+ & > .virtualized-markdown--diagram-action {
302
+ color: $primary;
303
+ text-style: underline;
304
+ }
305
+ & > .virtualized-markdown--hr {
306
+ color: $secondary;
307
+ }
308
+ & > .virtualized-markdown--block-quote {
309
+ }
310
+ &:dark > .virtualized-markdown--block-quote-border {
311
+ color: $text-primary 50%;
312
+ }
313
+ &:light > .virtualized-markdown--block-quote-border {
314
+ color: $text-secondary;
315
+ }
316
+ &:dark > .virtualized-markdown--bullet {
317
+ color: $text-primary;
318
+ }
319
+ &:light > .virtualized-markdown--bullet {
320
+ color: $text-secondary;
321
+ }
322
+ & > .virtualized-markdown--table {
323
+ }
324
+ &:light > .virtualized-markdown--table {
325
+ background: white 30%;
326
+ }
327
+ & > .virtualized-markdown--table-header {
328
+ color: $primary;
329
+ text-style: bold;
330
+ }
331
+ &:dark > .code_inline {
332
+ background: $warning 10%;
333
+ color: $text-warning 95%;
334
+ }
335
+ &:light > .code_inline {
336
+ background: $error 5%;
337
+ color: $text-error 95%;
338
+ }
339
+ & > .em {
340
+ text-style: italic;
341
+ }
342
+ & > .strong {
343
+ text-style: bold;
344
+ }
345
+ & > .s {
346
+ text-style: strike;
347
+ }
348
+ }
349
+ """
350
+
351
+ class TableOfContentsUpdated(Message):
352
+ """The table of contents was updated."""
353
+
354
+ def __init__(self, markdown: VirtualizedMarkdown, table_of_contents: TableOfContentsType | None) -> None:
355
+ super().__init__()
356
+ self.markdown: VirtualizedMarkdown = markdown
357
+ """The `VirtualizedMarkdown` widget associated with the table of contents."""
358
+ self.table_of_contents: TableOfContentsType | None = table_of_contents
359
+ """Table of contents."""
360
+
361
+ @property
362
+ def control(self) -> VirtualizedMarkdown:
363
+ """The `VirtualizedMarkdown` widget associated with the table of contents."""
364
+ return self.markdown
365
+
366
+ class TableOfContentsSelected(Message):
367
+ """An item in the TOC was selected."""
368
+
369
+ def __init__(self, markdown: VirtualizedMarkdown, block_id: str) -> None:
370
+ super().__init__()
371
+ self.markdown: VirtualizedMarkdown = markdown
372
+ """The `VirtualizedMarkdown` widget where the selected item is."""
373
+ self.block_id: str = block_id
374
+ """ID of the block that was selected."""
375
+
376
+ @property
377
+ def control(self) -> VirtualizedMarkdown:
378
+ """The `VirtualizedMarkdown` widget where the selected item is."""
379
+ return self.markdown
380
+
381
+ class LinkClicked(Message):
382
+ """A link in the document was clicked."""
383
+
384
+ def __init__(self, markdown: VirtualizedMarkdown, href: str) -> None:
385
+ super().__init__()
386
+ self.markdown: VirtualizedMarkdown = markdown
387
+ """The `VirtualizedMarkdown` widget containing the link clicked."""
388
+ self.raw_href: str = href
389
+ """The original link target, retaining percent-encoding for external opening."""
390
+ self.href: str = unquote(href)
391
+ """The decoded link target for local document and anchor navigation."""
392
+
393
+ @property
394
+ def control(self) -> VirtualizedMarkdown:
395
+ """The `VirtualizedMarkdown` widget containing the link clicked."""
396
+ return self.markdown
397
+
398
+ def __init__(
399
+ self,
400
+ markdown: str | None = None,
401
+ *,
402
+ name: str | None = None,
403
+ id: str | None = None,
404
+ classes: str | None = None,
405
+ parser_factory: Callable[[], MarkdownIt] | None = None,
406
+ open_links: bool = True,
407
+ ):
408
+ """A VirtualizedMarkdown widget.
409
+
410
+ Args:
411
+ markdown: String containing Markdown or None to leave blank for now.
412
+ name: The name of the widget.
413
+ id: The ID of the widget in the DOM.
414
+ classes: The CSS classes of the widget.
415
+ parser_factory: A factory function to return a configured MarkdownIt instance. If `None`, a "gfm-like" parser is used.
416
+ open_links: Open external links on click and expose terminal hyperlinks. If `False`,
417
+ only LinkClicked events and internal anchor navigation remain enabled.
418
+ """
419
+ super().__init__(name=name, id=id, classes=classes)
420
+ self._initial_markdown: str | None = markdown
421
+ self._markdown = ""
422
+ self._parser_factory = parser_factory
423
+ self._table_of_contents: TableOfContentsType | None = None
424
+ self._open_links = open_links
425
+ self._last_parsed_line = 0
426
+ self._pending_parse: _PendingParse | None = None
427
+ """The scheduled parse that has not read ``_markdown`` yet; new requests join it."""
428
+
429
+ # Virtualization state
430
+ self._blocks: list[MarkdownBlock] = []
431
+ """Parsed markdown blocks."""
432
+ self._block_line_info: list[_BlockLineInfo] = []
433
+ """Line information for each block."""
434
+ self._total_lines: int = 0
435
+ """Total virtual lines."""
436
+ self._line_cache: LRUCache[tuple[int, int], Strip] = LRUCache(maxsize=2048)
437
+ """Cache of rendered strips, keyed by (line_number, width)."""
438
+ self._table_layouts: dict[int, _TableLayout] = {}
439
+ """Layout metadata for table blocks, keyed by block index."""
440
+ self._table_strips: LRUCache[tuple[int, int], Strip] = LRUCache(maxsize=4096)
441
+ """Lazily-rendered table strips, keyed by (block index, content line)."""
442
+ self._fence_layouts: dict[int, _FenceLayout] = {}
443
+ """Source-line layout metadata for fenced code blocks, keyed by block index."""
444
+ self._fence_line_strips: LRUCache[tuple[int, int], Strip] = LRUCache(maxsize=4096)
445
+ """Lazily-rendered fence strips, keyed by (block index, content line)."""
446
+ self._fence_source_line_strips: LRUCache[tuple[int, int], list[Strip]] = LRUCache(maxsize=1024)
447
+ """Wrapped source-line strips for fence lines, keyed by (block index, source line)."""
448
+ self._diagram_layouts: dict[int, _DiagramLayout] = {}
449
+ """Inline preview layout metadata for diagram blocks."""
450
+ self._diagram_action_lines: dict[int, int] = {}
451
+ """Virtual action line to diagram block index."""
452
+ self._diagram_action_widths: dict[int, int] = {}
453
+ """Rendered action widths keyed by virtual line, kept in sync with cached strips."""
454
+ self._diagram_compile_cache: OrderedDict[tuple[str, str], CompiledDiagram] = OrderedDict()
455
+ """Small per-widget cache reused across streaming Markdown reparses."""
456
+ self._math_compile_cache: OrderedDict[str, CompiledMath] = OrderedDict()
457
+ self._math_rows: dict[int, tuple[str, ...]] = {}
458
+ self._block_strips: dict[int, list[Strip]] = {}
459
+ """Cached strips for long non-table/fence blocks, keyed by block index.
460
+
461
+ Paragraphs and similar blocks have the same cold-scroll shape as
462
+ fences: ``Content.render_strips`` renders the entire wrapped block even
463
+ when callers need a single row. Cache only blocks taller than the
464
+ visible crop so short blocks still rely on the smaller per-line cache."""
465
+ self._block_style_cache: dict[int, Style] = {}
466
+ """Resolved per-block visual style. Invalidated on theme / parse changes.
467
+
468
+ ``_render_block_line`` runs once per cold row, and each call would
469
+ otherwise walk the CSS component-style chain (``_get_block_style`` to
470
+ ``_safe_component_style`` to ``get_visual_style``). Memoizing per
471
+ block index keeps that walk to at most once per block per layout."""
472
+ self._width_at_last_layout: int = 0
473
+ """Width when blocks were last laid out."""
474
+ self._plain_selection_cache: tuple[list[str], list[tuple[str, int]]] | None = None
475
+ """Cached plain text rows and inter-row separators for text selection / copy.
476
+ Built lazily from rendered strips on first get_selection call."""
477
+ self._frame_scroll_y: int = 0
478
+ self._frame_width: int = 0
479
+ self._frame_visible_height: int = 0
480
+ self._frame_selection: object = None
481
+ self._lines_cache_key: tuple[int, int, int, int, int, int, str, str] = (
482
+ -1,
483
+ -1,
484
+ -1,
485
+ -1,
486
+ -1,
487
+ -1,
488
+ "",
489
+ "",
490
+ )
491
+ self._lines_cache_result: list[Strip] | None = None
492
+
493
+ # Seed ``virtual_size`` with a synchronous estimate so the widget
494
+ # never reports ``(0, 0)`` between mount and ``_on_mount``'s async
495
+ # ``update()``. Without this, an outside reflow that lands inside
496
+ # the executor await (e.g. ApprovalDialog dismiss on Windows) sees
497
+ # height 0 and causes the parent ChatPanel's anchor pin to compute
498
+ # against a transiently short ``virtual_h`` — visible to the user
499
+ # as a chat scroll-up / scroll-to-top. The estimate is a lower
500
+ # bound (no wrap counted) — ``_layout_blocks`` overrides with the
501
+ # real value once width is known. ``set_reactive`` skips watchers
502
+ # which is required pre-mount.
503
+ if markdown:
504
+ line_estimate = max(1, markdown.count("\n") + 1)
505
+ self.set_reactive(Widget.virtual_size, Size(80, line_estimate))
506
+
507
+ @property
508
+ def allow_select(self) -> bool:
509
+ """Allow text selection even though ScrollView is a container."""
510
+ return True
511
+
512
+ @property
513
+ def table_of_contents(self) -> TableOfContentsType:
514
+ """The document's table of contents."""
515
+ if self._table_of_contents is None:
516
+ self._table_of_contents = [
517
+ (block.level, block.content.plain, block.block_id)
518
+ for block in self._blocks
519
+ if block.block_type == "heading"
520
+ ]
521
+ return self._table_of_contents
522
+
523
+ @property
524
+ def source(self) -> str:
525
+ """The markdown source."""
526
+ return self._markdown or ""
527
+
528
+ async def _on_mount(self, _: Mount) -> None: # ty: ignore[invalid-method-override] # Textual dispatch awaits async handlers.
529
+ initial_markdown = self._initial_markdown
530
+ self._initial_markdown = None
531
+ await self.update(initial_markdown or "")
532
+
533
+ if initial_markdown is None:
534
+ self.post_message(
535
+ VirtualizedMarkdown.TableOfContentsUpdated(self, self._table_of_contents).set_sender(self)
536
+ )
537
+
538
+ @classmethod
539
+ def get_stream(cls, markdown: VirtualizedMarkdown) -> MarkdownStream:
540
+ """Get a MarkdownStream instance.
541
+
542
+ Args:
543
+ markdown: A VirtualizedMarkdown widget instance.
544
+
545
+ Returns:
546
+ The Markdown stream object.
547
+ """
548
+ updater = MarkdownStream(markdown)
549
+ updater.start()
550
+ return updater
551
+
552
+ def prepare_for_gc_freeze(self) -> None:
553
+ """Detach cyclic render LRUs before the permanent generation changes."""
554
+ # gc-freeze swaps in an acyclic capacity token; renew restores a real cache before next use.
555
+ self._line_cache = detach_lru_cache(self._line_cache) # ty: ignore[invalid-assignment]
556
+ self._table_strips = detach_lru_cache(self._table_strips) # ty: ignore[invalid-assignment]
557
+ self._fence_line_strips = detach_lru_cache(self._fence_line_strips) # ty: ignore[invalid-assignment]
558
+ self._fence_source_line_strips = detach_lru_cache(self._fence_source_line_strips) # ty: ignore[invalid-assignment]
559
+ self._lines_cache_result = None
560
+
561
+ def after_gc_freeze(self) -> None:
562
+ """Recreate render LRUs after the permanent generation changes."""
563
+ self._line_cache = renew_lru_cache(self._line_cache)
564
+ self._table_strips = renew_lru_cache(self._table_strips)
565
+ self._fence_line_strips = renew_lru_cache(self._fence_line_strips)
566
+ self._fence_source_line_strips = renew_lru_cache(self._fence_source_line_strips)
567
+
568
+ def abort_gc_freeze(self) -> None:
569
+ """Restore any render LRUs left detached by an incomplete hook pass."""
570
+ self.after_gc_freeze()
571
+
572
+ def on_virtualized_markdown_link_clicked(self, event: LinkClicked) -> None:
573
+ href = event.href
574
+ if href.startswith("#"):
575
+ self.goto_anchor(href[1:])
576
+ elif self._open_links and (target := external_link_target(event.raw_href)) is not None:
577
+ self.app.open_url(target)
578
+
579
+ def on_click(self, event: Click) -> None:
580
+ """Open a diagram only when its dedicated action row is clicked."""
581
+ block_index = self._diagram_action_at(event.get_content_offset(self))
582
+ if block_index is None:
583
+ return
584
+ self._request_diagram_open(block_index)
585
+ event.prevent_default()
586
+ event.stop()
587
+
588
+ def _diagram_action_at(self, offset: Offset | None) -> int | None:
589
+ """Return the diagram action under a viewport-relative content coordinate."""
590
+ if offset is None:
591
+ return None
592
+ virtual_y = round(self.scroll_offset.y) + offset.y
593
+ block_index = self._diagram_action_lines.get(virtual_y)
594
+ if block_index is None:
595
+ return None
596
+ block = self._blocks[block_index]
597
+ border_width = len(block.border_left) if block.border_left else 0
598
+ left_offset = block.indent + block.padding_left + border_width
599
+ content_width = (
600
+ self._width_at_last_layout - block.indent - block.padding_left - block.padding_right - border_width - 1
601
+ )
602
+ if content_width <= 0:
603
+ content_width = 1
604
+ # Use the width recorded when the cached strip was rendered. Transcript
605
+ # rows deliberately keep their existing language across a live locale
606
+ # switch, so resolving the new translation here would desynchronize the
607
+ # hitbox from the still-visible old-language action.
608
+ action_width = self._diagram_action_widths.get(virtual_y)
609
+ if action_width is None:
610
+ action_text = render_str(widget_localizer(self), _DIAGRAM_OPEN.bind())
611
+ action_width = min(content_width, cell_len(action_text))
612
+ action_start = left_offset + content_width - action_width
613
+ return block_index if action_start <= offset.x < action_start + action_width else None
614
+
615
+ def on_mouse_move(self, event: MouseMove) -> None:
616
+ pointer = "pointer" if self._diagram_action_at(event.get_content_offset(self)) is not None else "default"
617
+ if self.styles.pointer != pointer:
618
+ self.styles.pointer = pointer
619
+
620
+ def on_leave(self, _event: Leave) -> None:
621
+ if self.styles.pointer != "default":
622
+ self.styles.pointer = "default"
623
+
624
+ def _request_diagram_open(self, block_index: int) -> bool:
625
+ """Post an expanded-view request for one compiled diagram block."""
626
+ if block_index < 0 or block_index >= len(self._blocks):
627
+ return False
628
+ diagram = self._blocks[block_index].diagram
629
+ if diagram is None:
630
+ return False
631
+ self.post_message(DiagramOpenRequested(diagram))
632
+ return True
633
+
634
+ @staticmethod
635
+ def sanitize_location(location: str) -> tuple[Path, str]:
636
+ """Given a location, break out the path and any anchor.
637
+
638
+ Args:
639
+ location: The location to sanitize.
640
+
641
+ Returns:
642
+ A tuple of the path to the location cleaned of any anchor, plus
643
+ the anchor (or an empty string if none was found).
644
+ """
645
+ return _sanitize_location(location)
646
+
647
+ def goto_anchor(self, anchor: str) -> bool:
648
+ """Try and find the given anchor in the current document.
649
+
650
+ Args:
651
+ anchor: The anchor to try and find.
652
+
653
+ Returns:
654
+ True when the anchor was found, False otherwise.
655
+ """
656
+ if self._table_of_contents is None:
657
+ return False
658
+ found = find_heading([title for _, title, _ in self._table_of_contents], anchor)
659
+ if found is None:
660
+ return False
661
+ header_id = self._table_of_contents[found][2]
662
+ for info in self._block_line_info:
663
+ block = self._blocks[info.block_index]
664
+ if block.block_id == header_id:
665
+ self.scroll_to(y=info.start_line, animate=False)
666
+ return True
667
+ return True
668
+
669
+ async def load(self, path: Path) -> None:
670
+ """Load a new Markdown document.
671
+
672
+ Args:
673
+ path: Path to the document.
674
+
675
+ Raises:
676
+ OSError: If there was some form of error loading the document.
677
+ """
678
+ path, anchor = self.sanitize_location(str(path))
679
+ data = await asyncio.get_running_loop().run_in_executor(None, partial(path.read_text, encoding="utf-8"))
680
+ await self.update(data)
681
+ if anchor:
682
+ self.goto_anchor(anchor)
683
+
684
+ def _build_blocks(self, markdown: str) -> list[MarkdownBlock]:
685
+ """Parse markdown source into blocks.
686
+
687
+ Args:
688
+ markdown: Markdown document string.
689
+
690
+ Returns:
691
+ A list of MarkdownBlock objects.
692
+ """
693
+ parser = (
694
+ _create_markdown_parser()
695
+ if self._parser_factory is None
696
+ else _configure_markdown_parser(self._parser_factory())
697
+ )
698
+ tokens = parser.parse(markdown)
699
+ return _parse_tokens(
700
+ tokens,
701
+ compile_diagram=self._compile_mermaid_cached,
702
+ open_links=self._open_links,
703
+ math_enabled=bool(parser.options.get(MATH_ENABLED, False)),
704
+ math_compiler=self._compile_math_cached,
705
+ )
706
+
707
+ def _compile_math_cached(self, source: str) -> CompiledMath:
708
+ """Bound both successful and failed compilations across streamed reparses."""
709
+ if len(source) > MAX_MATH_SOURCE:
710
+ return CompiledMath(source)
711
+ cached = self._math_compile_cache.get(source)
712
+ if cached is not None:
713
+ self._math_compile_cache.move_to_end(source)
714
+ return cached
715
+ result = compile_math(source)
716
+ self._math_compile_cache[source] = result
717
+ while len(self._math_compile_cache) > 64:
718
+ self._math_compile_cache.popitem(last=False)
719
+ return result
720
+
721
+ def _compile_mermaid_cached(self, source: str) -> CompiledDiagram:
722
+ """Compile one closed Mermaid fence, reusing stable streamed blocks."""
723
+ localizer = widget_localizer(self)
724
+ cache_key = (localizer.effective_locale, source)
725
+ cached = self._diagram_compile_cache.get(cache_key)
726
+ if cached is not None:
727
+ self._diagram_compile_cache.move_to_end(cache_key)
728
+ return cached
729
+
730
+ diagram = compile_mermaid(source, render_message=localizer.render)
731
+ self._diagram_compile_cache[cache_key] = diagram
732
+ self._diagram_compile_cache.move_to_end(cache_key)
733
+ while len(self._diagram_compile_cache) > _DIAGRAM_COMPILE_CACHE_SIZE:
734
+ self._diagram_compile_cache.popitem(last=False)
735
+ return diagram
736
+
737
+ def _layout_blocks(self, width: int | None = None) -> None:
738
+ """Compute the line layout for all blocks."""
739
+ if width is None:
740
+ width = self.scrollable_content_region.width
741
+ if width <= 0:
742
+ width = 80
743
+
744
+ self._width_at_last_layout = width
745
+ self._block_line_info.clear()
746
+ self._line_cache.clear()
747
+ self._table_layouts.clear()
748
+ self._table_strips.clear()
749
+ self._fence_layouts.clear()
750
+ self._fence_line_strips.clear()
751
+ self._fence_source_line_strips.clear()
752
+ self._diagram_layouts.clear()
753
+ self._math_rows.clear()
754
+ self._diagram_action_lines.clear()
755
+ self._diagram_action_widths.clear()
756
+ self._block_strips.clear()
757
+ self._block_style_cache.clear()
758
+
759
+ current_line = 0
760
+ last_bottom_margin = 0
761
+ render_rules = self.styles.get_render_rules()
762
+
763
+ for index, block in enumerate(self._blocks):
764
+ top_margin = max(block.top_margin, last_bottom_margin) - last_bottom_margin
765
+ if index == 0:
766
+ top_margin = 0
767
+
768
+ border_width = len(block.border_left) if block.border_left else 0
769
+ # -1 reserves the right margin char appended by _render_block_line
770
+ content_width = width - block.indent - block.padding_left - block.padding_right - border_width - 1
771
+ if content_width <= 0:
772
+ content_width = 1
773
+
774
+ if block.block_type == "hr":
775
+ content_height = 1
776
+ elif block.block_type == "table" and block.table_headers is not None:
777
+ table_layout = self._build_table_layout(block, content_width)
778
+ self._table_layouts[index] = table_layout
779
+ content_height = table_layout.total_height
780
+ elif block.block_type == "fence":
781
+ fence_layout = self._build_fence_layout(block, content_width, render_rules)
782
+ self._fence_layouts[index] = fence_layout
783
+ content_height = fence_layout.total_height
784
+ elif block.block_type == "diagram" and block.diagram is not None:
785
+ preview_height = min(block.diagram.height, _INLINE_DIAGRAM_MAX_HEIGHT)
786
+ diagram_layout = _DiagramLayout(preview_height=preview_height)
787
+ self._diagram_layouts[index] = diagram_layout
788
+ content_height = diagram_layout.total_height
789
+ elif block.block_type == "math" and block.math is not None:
790
+ if block.math.rows and block.math.width <= content_width:
791
+ self._math_rows[index] = block.math.rows
792
+ content_height = len(block.math.rows)
793
+ else:
794
+ # Source fallback uses the same wrapping and clipboard
795
+ # mapping as ordinary text, so soft wraps never split TeX.
796
+ content_height = block.content.get_height({}, content_width)
797
+ else:
798
+ content_height = block.content.get_height({}, content_width)
799
+
800
+ content_height += block.padding_top + block.padding_bottom
801
+
802
+ total_height = top_margin + content_height + block.bottom_margin
803
+
804
+ self._block_line_info.append(
805
+ _BlockLineInfo(
806
+ block_index=index,
807
+ start_line=current_line,
808
+ height=total_height,
809
+ content_height=content_height,
810
+ top_margin=top_margin,
811
+ bottom_margin=block.bottom_margin,
812
+ )
813
+ )
814
+
815
+ if block.block_type == "diagram" and index in self._diagram_layouts:
816
+ action_line = (
817
+ current_line + top_margin + block.padding_top + self._diagram_layouts[index].preview_height
818
+ )
819
+ self._diagram_action_lines[action_line] = index
820
+
821
+ current_line += total_height
822
+ last_bottom_margin = block.bottom_margin
823
+
824
+ self._plain_selection_cache = None
825
+ self._lines_cache_result = None
826
+ self._total_lines = current_line - last_bottom_margin
827
+ self.virtual_size = Size(width, self._total_lines)
828
+ self._refresh_scrollbars()
829
+
830
+ def _converge_layout(self) -> None:
831
+ """Re-layout until the scrollbar-adjusted content width is stable.
832
+
833
+ ``_layout_blocks`` sets ``virtual_size`` and refreshes the scrollbars,
834
+ which can toggle the vertical scrollbar and shift
835
+ ``scrollable_content_region.width`` by one. Laying out a single time
836
+ would then paint strips (including cached table/block strips) sized for
837
+ the pre-scrollbar width into the post-scrollbar viewport for one frame —
838
+ the 1-column offset seen while resizing. Bounded so a borderline width
839
+ that flip-flops can't spin.
840
+ """
841
+ if not self._blocks:
842
+ return
843
+ for _ in range(3):
844
+ width = self.scrollable_content_region.width
845
+ if width <= 0 or self._width_at_last_layout == width:
846
+ return
847
+ self._layout_blocks()
848
+
849
+ def on_resize(self, _event: Resize) -> None:
850
+ """Eagerly update layout when the viewport width changes.
851
+
852
+ Waiting until ``render_lines`` to discover a new width is too late for
853
+ parent scroll/resize fast paths: they may compute dirty spans and child
854
+ heights from the previous table wrap. Keeping ``virtual_size`` current
855
+ before the next paint gives Textual the right geometry to invalidate.
856
+ """
857
+ width = self.scrollable_content_region.width
858
+ if width <= 0 or not self._blocks or self._width_at_last_layout == width:
859
+ return
860
+ self._converge_layout()
861
+ self.refresh(layout=True)
862
+
863
+ def _find_block_at_line(self, line: int) -> tuple[int, _BlockLineInfo] | None:
864
+ """Find which block contains a given virtual line using binary search."""
865
+ if not self._block_line_info:
866
+ return None
867
+
868
+ infos = self._block_line_info
869
+ lo, hi = 0, len(infos) - 1
870
+ while lo <= hi:
871
+ mid = (lo + hi) // 2
872
+ info = infos[mid]
873
+ if line < info.start_line:
874
+ hi = mid - 1
875
+ elif line >= info.start_line + info.height:
876
+ lo = mid + 1
877
+ else:
878
+ return mid, info
879
+ return None
880
+
881
+ def _render_block_line(self, block: MarkdownBlock, info: _BlockLineInfo, line: int, width: int) -> Strip:
882
+ """Render a single virtual line from a block."""
883
+ local_line = line - info.start_line
884
+
885
+ base_style = self.visual_style
886
+ block_style = self._get_block_style_cached(info.block_index, block)
887
+
888
+ if local_line < info.top_margin:
889
+ return self._render_margin_line(info.block_index - 1, info.block_index, width)
890
+
891
+ content_end = info.top_margin + info.content_height
892
+ if local_line >= content_end:
893
+ return self._render_margin_line(info.block_index, info.block_index + 1, width)
894
+
895
+ content_line = local_line - info.top_margin
896
+
897
+ if content_line < block.padding_top:
898
+ return self._render_padding_line(block, base_style, block_style, width)
899
+
900
+ actual_content_end = info.content_height - block.padding_bottom
901
+ if content_line >= actual_content_end:
902
+ return self._render_padding_line(block, base_style, block_style, width)
903
+
904
+ actual_line = content_line - block.padding_top
905
+
906
+ border_width = len(block.border_left) if block.border_left else 0
907
+ # -1 reserves the right margin char appended at the end of this method
908
+ content_width = width - block.indent - block.padding_left - block.padding_right - border_width - 1
909
+ if content_width <= 0:
910
+ content_width = 1
911
+
912
+ if block.block_type == "hr":
913
+ strip = Strip([Segment("─" * content_width, block_style.rich_style)], content_width)
914
+ elif block.block_type == "table" and info.block_index in self._table_layouts:
915
+ cache_key = (info.block_index, actual_line)
916
+ strip = self._table_strips.get(cache_key)
917
+ if strip is None:
918
+ strip = self._render_table_line(block, info.block_index, actual_line, content_width, block_style)
919
+ self._table_strips[cache_key] = strip
920
+ elif block.block_type == "fence":
921
+ cache_key = (info.block_index, actual_line)
922
+ strip = self._fence_line_strips.get(cache_key)
923
+ if strip is None:
924
+ strip = self._render_fence_line(block, info.block_index, actual_line, content_width, block_style)
925
+ self._fence_line_strips[cache_key] = strip
926
+ elif block.block_type == "math" and info.block_index in self._math_rows:
927
+ rows = self._math_rows[info.block_index]
928
+ row = rows[actual_line] if actual_line < len(rows) else ""
929
+ strip = Strip([Segment(row, block_style.rich_style)], cell_len(row))
930
+ elif block.block_type == "diagram" and block.diagram is not None:
931
+ diagram_layout = self._diagram_layouts.get(info.block_index)
932
+ if diagram_layout is None:
933
+ strip = Strip.blank(content_width, block_style.rich_style)
934
+ elif actual_line < diagram_layout.preview_height:
935
+ row = block.diagram.crop_plain_row(actual_line, 0, content_width)
936
+ strip = Strip([Segment(row, block_style.rich_style)], content_width)
937
+ else:
938
+ action_text = render_str(widget_localizer(self), _DIAGRAM_OPEN.bind())
939
+ action_width = min(content_width, cell_len(action_text))
940
+ self._diagram_action_widths[line] = action_width
941
+ action_text = set_cell_size(action_text, action_width)
942
+ action_component_style = self._safe_component_style("virtualized-markdown--diagram-action").rich_style
943
+ action_style = RichStyle(
944
+ color=action_component_style.color,
945
+ bgcolor=block_style.rich_style.bgcolor,
946
+ underline=action_component_style.underline,
947
+ )
948
+ strip = Strip(
949
+ [
950
+ Segment(" " * (content_width - action_width), block_style.rich_style),
951
+ Segment(action_text, action_style),
952
+ ],
953
+ content_width,
954
+ )
955
+ else:
956
+ strips = self._get_long_block_strips(block, info, content_width, block_style)
957
+ if strips is None:
958
+ strips = self._build_block_strips(block, content_width, block_style)
959
+
960
+ if actual_line < len(strips):
961
+ strip = strips[actual_line]
962
+ else:
963
+ strip = Strip.blank(content_width, block_style.rich_style)
964
+
965
+ if block.text_align == "center":
966
+ strip_len = strip.cell_length
967
+ if strip_len < content_width:
968
+ pad_left = (content_width - strip_len) // 2
969
+ pad_right = content_width - strip_len - pad_left
970
+ strip = Strip(
971
+ [
972
+ Segment(" " * pad_left, block_style.rich_style),
973
+ *strip._segments,
974
+ Segment(" " * pad_right, block_style.rich_style),
975
+ ],
976
+ content_width,
977
+ )
978
+
979
+ left_offset = block.indent + block.padding_left + border_width
980
+ if left_offset > 0 or block.prefix:
981
+ segments = self._render_block_gutter(block, first_line=actual_line == 0)
982
+ if block.padding_left > 0:
983
+ segments.append(Segment(" " * block.padding_left, block_style.rich_style))
984
+ segments.extend(strip._segments)
985
+ if block.padding_right > 0:
986
+ segments.append(Segment(" " * block.padding_right, block_style.rich_style))
987
+ strip = Strip(segments)
988
+
989
+ pad_rich_style = block_style.rich_style
990
+ if pad_rich_style.underline or pad_rich_style.overline or pad_rich_style.strike:
991
+ pad_rich_style = RichStyle(
992
+ color=pad_rich_style.color,
993
+ bgcolor=pad_rich_style.bgcolor,
994
+ bold=pad_rich_style.bold,
995
+ dim=pad_rich_style.dim,
996
+ italic=pad_rich_style.italic,
997
+ )
998
+ if width > 1:
999
+ strip = strip.adjust_cell_length(width - 1, pad_rich_style)
1000
+ strip = Strip(
1001
+ [*strip._segments, Segment(" ", base_style.rich_style)],
1002
+ width,
1003
+ )
1004
+ else:
1005
+ strip = strip.extend_cell_length(width, pad_rich_style)
1006
+
1007
+ return strip
1008
+
1009
+ def _safe_component_style(self, *names: str) -> Style:
1010
+ """Get a visual style, falling back to base style if component styles aren't ready.
1011
+
1012
+ During CSS reapplication (e.g. on focus change), ``_component_styles``
1013
+ is briefly cleared before being repopulated. If a render is triggered
1014
+ in that window, ``get_visual_style`` raises ``KeyError``. This helper
1015
+ catches that and returns the widget's base visual style instead.
1016
+ """
1017
+ try:
1018
+ return self.get_visual_style(*names)
1019
+ except KeyError:
1020
+ return self.visual_style
1021
+
1022
+ def _get_block_style(self, block: MarkdownBlock) -> Style:
1023
+ """Get the visual style for a block."""
1024
+ if block.bq_depth > 0 and block.block_type == "paragraph":
1025
+ return self._get_bq_depth_style(block.bq_depth)
1026
+ style = self._safe_component_style(block.style_name) if block.style_name else self.visual_style
1027
+ if block.bq_depth > 0 and block.block_type not in {"diagram", "fence", "math"}:
1028
+ style = replace(style, background=self._get_bq_depth_style(block.bq_depth).background)
1029
+ return style
1030
+
1031
+ def _get_block_style_cached(self, block_index: int, block: MarkdownBlock) -> Style:
1032
+ """Memoized :meth:`_get_block_style` for use in the per-row hot path.
1033
+
1034
+ Cache is keyed by block index and invalidated wherever the layout or
1035
+ component-style resolution can change (``_layout_blocks``,
1036
+ ``notify_style_update``, ``update``, ``append``)."""
1037
+ cached = self._block_style_cache.get(block_index)
1038
+ if cached is not None:
1039
+ return cached
1040
+ style = self._get_block_style(block)
1041
+ self._block_style_cache[block_index] = style
1042
+ return style
1043
+
1044
+ def _get_bq_depth_style(self, depth: int) -> Style:
1045
+ """Compute a Style with a compounding background for blockquote depth."""
1046
+ base_style = self._safe_component_style("virtualized-markdown--block-quote")
1047
+ base_bg = base_style.background or self.visual_style.background
1048
+ if base_bg is None:
1049
+ return base_style
1050
+
1051
+ contrast = base_bg.get_contrast_text(1.0)
1052
+ blended = base_bg
1053
+ boost_factor = 0.04
1054
+ for _ in range(depth):
1055
+ blended = blended.blend(contrast, boost_factor, alpha=1.0)
1056
+
1057
+ return replace(base_style, background=blended)
1058
+
1059
+ def _render_gutter(self, gutter: tuple[MarkdownGutter, ...], *, first_line: bool = False) -> list[Segment]:
1060
+ """Paint containers in source order, keeping list markers inside their quotes."""
1061
+ bq_border_style = self._safe_component_style("virtualized-markdown--block-quote-border")
1062
+ segments: list[Segment] = []
1063
+ depth = 0
1064
+ style = self.visual_style.rich_style
1065
+ for part in gutter:
1066
+ if part.kind == "quote":
1067
+ depth += 1
1068
+ style = self._get_bq_depth_style(depth).rich_style
1069
+ segments.append(Segment("▌", RichStyle(color=bq_border_style.rich_style.color, bgcolor=style.bgcolor)))
1070
+ segments.append(Segment(" ", style))
1071
+ else:
1072
+ prefix = part.prefix if first_line else ""
1073
+ segments.append(Segment(" " * (part.width - cell_len(prefix)), style))
1074
+ if prefix:
1075
+ bullet_style = self._safe_component_style("virtualized-markdown--bullet").rich_style
1076
+ segments.append(Segment(prefix, bullet_style + RichStyle(bgcolor=style.bgcolor)))
1077
+ return segments
1078
+
1079
+ def _render_block_gutter(self, block: MarkdownBlock, *, first_line: bool = False) -> list[Segment]:
1080
+ if block.gutter:
1081
+ return self._render_gutter(block.gutter, first_line=first_line)
1082
+ # External token handlers may still provide the flat block attributes.
1083
+ prefix = block.prefix if first_line else ""
1084
+ segments = [Segment(" " * max(0, block.indent - cell_len(prefix)), self.visual_style.rich_style)]
1085
+ if prefix:
1086
+ segments.append(Segment(prefix, self._safe_component_style("virtualized-markdown--bullet").rich_style))
1087
+ if block.border_left:
1088
+ segments.append(Segment(block.border_left, self._get_block_style(block).rich_style))
1089
+ return segments
1090
+
1091
+ def _render_margin_line(self, previous: int, following: int, width: int) -> Strip:
1092
+ """Keep only shared container borders across the gap between two blocks."""
1093
+ shared: list[MarkdownGutter] = []
1094
+ if previous >= 0 and following < len(self._blocks):
1095
+ for left, right in zip(self._blocks[previous].gutter, self._blocks[following].gutter, strict=False):
1096
+ if left.container_id != right.container_id:
1097
+ break
1098
+ shared.append(left)
1099
+ depth = sum(part.kind == "quote" for part in shared)
1100
+ style = self._get_bq_depth_style(depth) if depth else self.visual_style
1101
+ strip = Strip(self._render_gutter(tuple(shared)))
1102
+ strip = strip.adjust_cell_length(max(0, width - 1), style.rich_style)
1103
+ return Strip([*strip._segments, Segment(" ", self.visual_style.rich_style)], width)
1104
+
1105
+ def _render_padding_line(self, block: MarkdownBlock, base_style: Style, block_style: Style, width: int) -> Strip:
1106
+ """Render a padding line (e.g. top/bottom padding of a code fence)."""
1107
+ border_width = len(block.border_left) if block.border_left else 0
1108
+ left_offset = block.indent + block.padding_left + border_width
1109
+ if left_offset > 0:
1110
+ segments = self._render_block_gutter(block)
1111
+ if block.padding_left > 0:
1112
+ segments.append(Segment(" " * block.padding_left, block_style.rich_style))
1113
+ remaining = width - left_offset
1114
+ if block.block_type in {"diagram", "fence"} and remaining > 1:
1115
+ segments.append(Segment(" " * (remaining - 1), block_style.rich_style))
1116
+ segments.append(Segment(" ", base_style.rich_style))
1117
+ elif remaining > 0:
1118
+ segments.append(Segment(" " * remaining, block_style.rich_style))
1119
+ return Strip(segments, width)
1120
+ if block.block_type in {"diagram", "fence"} and width > 1:
1121
+ return Strip(
1122
+ [
1123
+ Segment(" " * (width - 1), block_style.rich_style),
1124
+ Segment(" ", base_style.rich_style),
1125
+ ],
1126
+ width,
1127
+ )
1128
+ return Strip.blank(width, block_style.rich_style)
1129
+
1130
+ def _build_table_layout(self, block: MarkdownBlock, content_width: int) -> _TableLayout:
1131
+ """Build table width/height metadata without rendering every row."""
1132
+ headers = block.table_headers or []
1133
+ rows = block.table_rows or []
1134
+ if not headers:
1135
+ return _TableLayout([], 0, [], [], 0)
1136
+
1137
+ col_count = len(headers)
1138
+ cell_pad = 1
1139
+
1140
+ overhead = col_count * (2 * cell_pad + 1) + 1
1141
+ available = content_width - overhead
1142
+ available = max(available, col_count)
1143
+
1144
+ nat_widths: list[int] = []
1145
+ min_widths: list[int] = []
1146
+ # A column holding any unbreakable 2-cell cluster (CJK glyph, emoji
1147
+ # sequence, etc.) needs at least 2 cells when space allows. ASCII-only
1148
+ # columns may shrink to 1 because they can wrap between characters.
1149
+ for header in headers:
1150
+ nat_widths.append(max(_cell_natural_width(header.plain), 1))
1151
+ min_widths.append(_cell_min_width(header.plain))
1152
+ for row in rows:
1153
+ for i, cell_content in enumerate(row):
1154
+ if i < col_count:
1155
+ nat_widths[i] = max(nat_widths[i], _cell_natural_width(cell_content.plain))
1156
+ min_widths[i] = max(min_widths[i], _cell_min_width(cell_content.plain))
1157
+
1158
+ total_nat = sum(nat_widths)
1159
+ if total_nat <= available:
1160
+ col_widths = nat_widths[:]
1161
+ else:
1162
+ # If the double-width floors can't all fit the content area, drop
1163
+ # them to 1 so the table still fits exactly (no right-edge crop that
1164
+ # would clip borders/data); cell rendering then truncates a glyph too
1165
+ # wide for its 1-cell column. ``available >= col_count`` (clamped
1166
+ # above), so a floor of 1 per column always fits.
1167
+ if sum(min_widths) > available:
1168
+ min_widths = [1] * col_count
1169
+ col_widths = _shrink_widest_columns(nat_widths, min_widths, available)
1170
+
1171
+ header_height = max(
1172
+ (_cell_wrapped_height(header.plain, col_widths[i]) for i, header in enumerate(headers)), default=1
1173
+ )
1174
+
1175
+ row_heights: list[int] = []
1176
+ for row in rows:
1177
+ height = 1
1178
+ for i in range(col_count):
1179
+ if i < len(row):
1180
+ height = max(height, _cell_wrapped_height(row[i].plain, col_widths[i]))
1181
+ row_heights.append(height)
1182
+
1183
+ row_starts: list[int] = []
1184
+ current_line = 2 + header_height
1185
+ for row_index, row_height in enumerate(row_heights):
1186
+ row_starts.append(current_line)
1187
+ current_line += row_height
1188
+ if row_index < len(row_heights) - 1:
1189
+ current_line += 1
1190
+
1191
+ # top border + header + header/body separator + body rows + body separators + bottom border
1192
+ total_height = current_line + 1
1193
+ return _TableLayout(
1194
+ col_widths=col_widths,
1195
+ header_height=header_height,
1196
+ row_heights=row_heights,
1197
+ row_starts=row_starts,
1198
+ total_height=total_height,
1199
+ )
1200
+
1201
+ def _table_border_style(self, block_style: Style) -> RichStyle:
1202
+ return RichStyle(
1203
+ color=block_style.rich_style.color,
1204
+ bgcolor=block_style.rich_style.bgcolor,
1205
+ dim=True,
1206
+ )
1207
+
1208
+ def _render_table_border(
1209
+ self,
1210
+ layout: _TableLayout,
1211
+ *,
1212
+ left: str,
1213
+ mid: str,
1214
+ right: str,
1215
+ border_rs: RichStyle,
1216
+ ) -> Strip:
1217
+ parts: list[str] = [left]
1218
+ for i, width in enumerate(layout.col_widths):
1219
+ parts.append("\u2500" * (width + 2))
1220
+ if i < len(layout.col_widths) - 1:
1221
+ parts.append(mid)
1222
+ parts.append(right)
1223
+ return Strip([Segment("".join(parts), border_rs)])
1224
+
1225
+ def _render_table_data_line(
1226
+ self,
1227
+ layout: _TableLayout,
1228
+ cells: list[Content],
1229
+ line_idx: int,
1230
+ *,
1231
+ text_style: Style,
1232
+ cell_rs: RichStyle,
1233
+ border_rs: RichStyle,
1234
+ ) -> Strip:
1235
+ segs: list[Segment] = [Segment("\u2502", border_rs)]
1236
+ for col_idx, col_width in enumerate(layout.col_widths):
1237
+ line_content = Content("")
1238
+ if col_idx < len(cells):
1239
+ content = cells[col_idx]
1240
+ cell_lines = _wrap_cell_text(content.plain, col_width)
1241
+ if line_idx < len(cell_lines):
1242
+ start = 0
1243
+ for previous_line in cell_lines[:line_idx]:
1244
+ start += len(previous_line)
1245
+ if content.plain[start : start + 1] == "\n":
1246
+ start += 1
1247
+ end = start + _cell_prefix_length(cell_lines[line_idx], col_width)
1248
+ line_content = _content_slice(content, start, end)
1249
+ segs.append(Segment(" ", cell_rs))
1250
+ segs.extend(
1251
+ Segment(text, style.rich_style)
1252
+ for text, style in line_content.render(text_style, end="", parse_style=self._get_style)
1253
+ if text
1254
+ )
1255
+ padding = col_width - cell_len(line_content.plain)
1256
+ if padding:
1257
+ # Padding and separators must not inherit a cell's final link.
1258
+ segs.append(Segment(" " * padding, text_style.rich_style))
1259
+ segs.append(Segment(" ", cell_rs))
1260
+ segs.append(Segment("\u2502", border_rs))
1261
+ return Strip(segs)
1262
+
1263
+ def _render_table_line(
1264
+ self,
1265
+ block: MarkdownBlock,
1266
+ block_index: int,
1267
+ actual_line: int,
1268
+ content_width: int,
1269
+ block_style: Style,
1270
+ ) -> Strip:
1271
+ """Render a single content line from a table block."""
1272
+ layout = self._table_layouts.get(block_index)
1273
+ headers = block.table_headers or []
1274
+ rows = block.table_rows or []
1275
+ if layout is None or not headers:
1276
+ return Strip.blank(content_width, block_style.rich_style)
1277
+
1278
+ header_style = self._safe_component_style("virtualized-markdown--table-header")
1279
+ if block.bq_depth:
1280
+ header_style = replace(header_style, background=block_style.background)
1281
+ cell_rs = block_style.rich_style
1282
+ border_rs = self._table_border_style(block_style)
1283
+
1284
+ if actual_line == 0:
1285
+ return self._render_table_border(layout, left="\u250c", mid="\u252c", right="\u2510", border_rs=border_rs)
1286
+
1287
+ header_end = 1 + layout.header_height
1288
+ if actual_line < header_end:
1289
+ return self._render_table_data_line(
1290
+ layout,
1291
+ headers,
1292
+ actual_line - 1,
1293
+ text_style=header_style,
1294
+ cell_rs=cell_rs,
1295
+ border_rs=border_rs,
1296
+ )
1297
+
1298
+ if actual_line == header_end:
1299
+ return self._render_table_border(layout, left="\u251c", mid="\u253c", right="\u2524", border_rs=border_rs)
1300
+
1301
+ if actual_line == layout.total_height - 1:
1302
+ return self._render_table_border(layout, left="\u2514", mid="\u2534", right="\u2518", border_rs=border_rs)
1303
+
1304
+ if not layout.row_starts:
1305
+ return Strip.blank(content_width, block_style.rich_style)
1306
+
1307
+ row_index = bisect_right(layout.row_starts, actual_line) - 1
1308
+ if row_index < 0 or row_index >= len(rows):
1309
+ return Strip.blank(content_width, block_style.rich_style)
1310
+
1311
+ row_start = layout.row_starts[row_index]
1312
+ row_height = layout.row_heights[row_index]
1313
+ row_line = actual_line - row_start
1314
+ if row_line < row_height:
1315
+ return self._render_table_data_line(
1316
+ layout,
1317
+ rows[row_index],
1318
+ row_line,
1319
+ text_style=block_style,
1320
+ cell_rs=cell_rs,
1321
+ border_rs=border_rs,
1322
+ )
1323
+
1324
+ return self._render_table_border(layout, left="\u251c", mid="\u253c", right="\u2524", border_rs=border_rs)
1325
+
1326
+ def _build_table_strips(self, block: MarkdownBlock, content_width: int) -> list[Strip]:
1327
+ """Build every strip for a table block.
1328
+
1329
+ This compatibility helper is intentionally not used by layout; table
1330
+ rows are rendered lazily through ``_render_table_line``. It is kept so
1331
+ profiler instrumentation and any external/debug callers that still look
1332
+ up ``_build_table_strips`` by name continue to work.
1333
+ """
1334
+ layout = self._build_table_layout(block, content_width)
1335
+ if layout.total_height <= 0:
1336
+ return []
1337
+ block_index = -1
1338
+ self._table_layouts[block_index] = layout
1339
+ block_style = self._get_block_style(block)
1340
+ try:
1341
+ return [
1342
+ self._render_table_line(block, block_index, actual_line, content_width, block_style)
1343
+ for actual_line in range(layout.total_height)
1344
+ ]
1345
+ finally:
1346
+ self._table_layouts.pop(block_index, None)
1347
+
1348
+ def _build_fence_layout(self, block: MarkdownBlock, content_width: int, render_rules: Any) -> _FenceLayout:
1349
+ """Build source-line heights for a fence without rendering styled strips."""
1350
+ text = block.content.plain
1351
+ line_ranges = _line_ranges(text)
1352
+
1353
+ line_starts: list[int] = []
1354
+ current_line = 0
1355
+ # ``Content.get_height`` wraps each line at the content width minus
1356
+ # both line pads and splits only past it, so a tab-free line whose
1357
+ # cells fit is exactly one row. Every character is at most two cells
1358
+ # wide, which lets short lines skip even the cell count; only tabbed
1359
+ # or possibly overlong lines pay for building Content and wrapping it.
1360
+ wrap_width = content_width - render_rules.get("line_pad", 0) * 2
1361
+ for start, end in line_ranges:
1362
+ line_starts.append(current_line)
1363
+ source = text[start:end]
1364
+ if "\t" not in source and (2 * (end - start) <= wrap_width or cell_len(source) <= wrap_width):
1365
+ current_line += 1
1366
+ continue
1367
+ line = Content(source, strip_control_codes=False)
1368
+ height = max(1, line.get_height(render_rules, content_width))
1369
+ current_line += height
1370
+
1371
+ return _FenceLayout(
1372
+ line_ranges=line_ranges,
1373
+ line_span_indices=_span_indices_by_line(block.content.spans, line_ranges),
1374
+ line_starts=line_starts,
1375
+ total_height=current_line,
1376
+ )
1377
+
1378
+ def _render_fence_line(
1379
+ self,
1380
+ block: MarkdownBlock,
1381
+ block_index: int,
1382
+ actual_line: int,
1383
+ content_width: int,
1384
+ block_style: Style,
1385
+ ) -> Strip:
1386
+ """Render one wrapped content line from a fenced code block."""
1387
+ layout = self._fence_layouts.get(block_index)
1388
+ if layout is None:
1389
+ layout = self._build_fence_layout(block, content_width, self.styles.get_render_rules())
1390
+ self._fence_layouts[block_index] = layout
1391
+ if actual_line < 0 or actual_line >= layout.total_height or not layout.line_starts:
1392
+ return Strip.blank(content_width, block_style.rich_style)
1393
+
1394
+ source_line_index = bisect_right(layout.line_starts, actual_line) - 1
1395
+ if source_line_index < 0 or source_line_index >= len(layout.line_ranges):
1396
+ return Strip.blank(content_width, block_style.rich_style)
1397
+
1398
+ source_cache_key = (block_index, source_line_index)
1399
+ source_strips = self._fence_source_line_strips.get(source_cache_key)
1400
+ if source_strips is None:
1401
+ source_start, source_end = layout.line_ranges[source_line_index]
1402
+ source_line = _content_slice(
1403
+ block.content,
1404
+ source_start,
1405
+ source_end,
1406
+ None if layout.line_span_indices is None else layout.line_span_indices[source_line_index],
1407
+ )
1408
+ render_options = RenderOptions(self._get_style, self.styles.get_render_rules())
1409
+ source_strips = source_line.render_strips(
1410
+ content_width,
1411
+ None,
1412
+ block_style,
1413
+ render_options,
1414
+ )
1415
+ self._fence_source_line_strips[source_cache_key] = source_strips
1416
+
1417
+ source_line_offset = actual_line - layout.line_starts[source_line_index]
1418
+ if source_line_offset < len(source_strips):
1419
+ return source_strips[source_line_offset]
1420
+ return Strip.blank(content_width, block_style.rich_style)
1421
+
1422
+ def _visible_height_threshold(self) -> int:
1423
+ """Return the current viewport-sized threshold for block-strip caching."""
1424
+ if self._frame_visible_height > 0:
1425
+ return self._frame_visible_height
1426
+ if self.is_attached:
1427
+ return max(1, self.screen.size.height)
1428
+ return 40
1429
+
1430
+ def _get_long_block_strips(
1431
+ self,
1432
+ block: MarkdownBlock,
1433
+ info: _BlockLineInfo,
1434
+ content_width: int,
1435
+ block_style: Style,
1436
+ ) -> list[Strip] | None:
1437
+ """Return cached strips for blocks large enough to exceed the visible crop."""
1438
+ if info.content_height <= self._visible_height_threshold():
1439
+ return None
1440
+
1441
+ strips = self._block_strips.get(info.block_index)
1442
+ if strips is None:
1443
+ strips = self._build_block_strips(block, content_width, block_style)
1444
+ self._block_strips[info.block_index] = strips
1445
+ return strips
1446
+
1447
+ def _build_block_strips(self, block: MarkdownBlock, content_width: int, block_style: Style) -> list[Strip]:
1448
+ """Render all strips for a non-table/fence markdown block."""
1449
+ render_options = RenderOptions(self._get_style, self.styles.get_render_rules())
1450
+ return block.content.render_strips(content_width, None, block_style, render_options)
1451
+
1452
+ def _build_fence_strips(self, block: MarkdownBlock, content_width: int, block_style: Style) -> list[Strip]:
1453
+ """Render and cache every strip of a fenced code block.
1454
+
1455
+ Compatibility helper for profilers/debug callers that still ask for
1456
+ the whole fence at once. Normal scrolling uses ``_render_fence_line``
1457
+ so a long offscreen fence doesn't block the UI thread when its first
1458
+ row becomes visible.
1459
+ """
1460
+ render_options = RenderOptions(self._get_style, self.styles.get_render_rules())
1461
+ return block.content.render_strips(content_width, None, block_style, render_options)
1462
+
1463
+ def render_line(self, y: int) -> Strip:
1464
+ """Render a line of content for the Line API."""
1465
+ if not self.is_attached:
1466
+ return Strip.blank(max(0, self.size.width)).apply_offsets(0, y)
1467
+
1468
+ self._converge_layout()
1469
+ width = self.scrollable_content_region.width
1470
+ if width <= 0:
1471
+ return Strip.blank(0)
1472
+
1473
+ line_number = round(self.scroll_offset.y) + y
1474
+
1475
+ cache_key = (line_number, width)
1476
+ cached = self._line_cache.get(cache_key)
1477
+ if cached is not None:
1478
+ strip = cached
1479
+ elif line_number >= self._total_lines or line_number < 0:
1480
+ strip = Strip.blank(width, self.visual_style.rich_style)
1481
+ self._line_cache[cache_key] = strip
1482
+ else:
1483
+ result = self._find_block_at_line(line_number)
1484
+ if result is None:
1485
+ strip = Strip.blank(width, self.visual_style.rich_style)
1486
+ else:
1487
+ _block_idx, info = result
1488
+ block = self._blocks[info.block_index]
1489
+ strip = self._render_block_line(block, info, line_number, width)
1490
+ self._line_cache[cache_key] = strip
1491
+
1492
+ # Apply selection highlight (post-cache so base strip stays clean)
1493
+ selection = self._safe_text_selection()
1494
+ if selection is not None:
1495
+ span = selection.get_span(line_number)
1496
+ if span is not None:
1497
+ strip = self._apply_selection_highlight(strip, span)
1498
+
1499
+ # Embed offset metadata for selection coordinate resolution after
1500
+ # highlighting. Highlighting splits segments, and same-row selection
1501
+ # hit-testing needs each split segment to carry its own row offset.
1502
+ strip = strip.apply_offsets(0, line_number)
1503
+
1504
+ return strip
1505
+
1506
+ def _safe_text_selection(self) -> Selection | None:
1507
+ try:
1508
+ return self.text_selection
1509
+ except NoScreen:
1510
+ return None
1511
+
1512
+ def _apply_selection_highlight(self, strip: Strip, span: tuple[int, int]) -> Strip:
1513
+ """Apply selection highlight style to a strip.
1514
+
1515
+ Args:
1516
+ strip: The strip to highlight.
1517
+ span: Tuple of (start_x, end_x) character offsets. end_x=-1 means end of line.
1518
+ """
1519
+ start_x, end_x = span
1520
+ selection_style = _normalize_selection_rich_style(self.screen.get_component_rich_style("screen--selection"))
1521
+ segments: list[Segment] = []
1522
+ pos = 0
1523
+ for seg in strip._segments:
1524
+ text, style, control = seg
1525
+ seg_start = pos
1526
+ seg_end = pos + len(text)
1527
+ actual_end = seg_end if end_x == -1 else min(seg_end, end_x)
1528
+
1529
+ if actual_end > seg_start and start_x < seg_end and seg_start < (seg_end if end_x == -1 else end_x):
1530
+ # This segment overlaps with selection
1531
+ sel_start = max(0, start_x - seg_start)
1532
+ sel_end = len(text) if end_x == -1 else min(len(text), end_x - seg_start)
1533
+
1534
+ if sel_start > 0:
1535
+ segments.append(Segment(text[:sel_start], style, control))
1536
+ combined = style + selection_style if style else selection_style
1537
+ segments.append(Segment(text[sel_start:sel_end], combined, control))
1538
+ if sel_end < len(text):
1539
+ segments.append(Segment(text[sel_end:], style, control))
1540
+ else:
1541
+ segments.append(seg)
1542
+ pos = seg_end
1543
+ return Strip(segments, strip.cell_length)
1544
+
1545
+ def notify_style_update(self) -> None:
1546
+ """Clear caches when theme or styles change so strips are re-rendered."""
1547
+ self._line_cache.clear()
1548
+ self._table_layouts.clear()
1549
+ self._table_strips.clear()
1550
+ self._fence_layouts.clear()
1551
+ self._fence_line_strips.clear()
1552
+ self._fence_source_line_strips.clear()
1553
+ self._block_strips.clear()
1554
+ self._block_style_cache.clear()
1555
+ self._plain_selection_cache = None
1556
+ self._width_at_last_layout = 0 # force _layout_blocks on next render
1557
+ self._lines_cache_result = None
1558
+ super().notify_style_update()
1559
+
1560
+ def render_lines(self, crop: Region) -> list[Strip]:
1561
+ """Render visible lines."""
1562
+ if not self.is_attached:
1563
+ return [Strip.blank(crop.width) for _ in crop.line_range]
1564
+
1565
+ self._converge_layout()
1566
+
1567
+ width = self.scrollable_content_region.width
1568
+ if width <= 0:
1569
+ return [Strip.blank(crop.width)] * crop.height
1570
+
1571
+ self._frame_scroll_y = round(self.scroll_offset.y)
1572
+ self._frame_width = width
1573
+ self._frame_visible_height = crop.height
1574
+ self._frame_selection = self._safe_text_selection()
1575
+
1576
+ if self._can_render_lines_direct(crop):
1577
+ hover_link_style = self._hover_link_style()
1578
+ lines_key = (
1579
+ self._frame_scroll_y,
1580
+ width,
1581
+ crop.x,
1582
+ crop.y,
1583
+ crop.width,
1584
+ crop.height,
1585
+ self.app.theme,
1586
+ str(self.app.console.color_system),
1587
+ )
1588
+ cacheable = self._frame_selection is None and hover_link_style is None
1589
+ if (
1590
+ cacheable
1591
+ and lines_key == self._lines_cache_key
1592
+ and self._lines_cache_result is not None
1593
+ and crop.height == len(self._lines_cache_result)
1594
+ ):
1595
+ return self._lines_cache_result
1596
+
1597
+ result = self._render_lines_direct(crop, hover_link_style)
1598
+ if cacheable:
1599
+ self._lines_cache_key = lines_key
1600
+ self._lines_cache_result = result
1601
+ else:
1602
+ self._lines_cache_result = None
1603
+ return result
1604
+
1605
+ self._lines_cache_result = None
1606
+ return super().render_lines(crop)
1607
+
1608
+ def _can_render_lines_direct(self, crop: Region) -> bool:
1609
+ """Return true when the chat markdown direct renderer preserves widget styling."""
1610
+ styles = self.styles
1611
+ if styles.gutter:
1612
+ return False
1613
+ if any(edge for edge, _color in styles.outline):
1614
+ return False
1615
+ if styles.has_rule("hatch") and styles.hatch != "none":
1616
+ return False
1617
+ if styles.tint.a:
1618
+ return False
1619
+ if styles.background_tint.a:
1620
+ return False
1621
+ if styles.text_opacity != 1.0 or self.opacity != 1.0:
1622
+ return False
1623
+ if styles.line_pad:
1624
+ return False
1625
+ if styles.text_align not in {"start", "left"}:
1626
+ return False
1627
+ if self.show_vertical_scrollbar or self.show_horizontal_scrollbar:
1628
+ return False
1629
+ return crop.width > 0 and self.content_region.width > 0
1630
+
1631
+ def _hover_link_style(self) -> RichStyle | None:
1632
+ """Return link hover style only when Textual would apply it."""
1633
+ hover_style = self.hover_style
1634
+ # Textual exposes no public predicate for clickable hover links, so
1635
+ # mirror StylesCache.render_widget's internal metadata checks here.
1636
+ if not self.auto_links or not hover_style._link_id or not hover_style._meta:
1637
+ return None
1638
+ if "@click" not in hover_style.meta:
1639
+ return None
1640
+ return self.link_style_hover
1641
+
1642
+ def _render_lines_direct(self, crop: Region, hover_link_style: RichStyle | None) -> list[Strip]:
1643
+ """Render clipped markdown rows without ``StylesCache.render``.
1644
+
1645
+ Chat message markdown has no padding, borders, filters, or internal
1646
+ scrollbars. In that case ``render_line`` already returns the final
1647
+ strip, and the generic style wrapper only adds per-row bookkeeping
1648
+ during parent chat scrolling.
1649
+ """
1650
+ content_width = self.content_region.width
1651
+ rich_style = self.visual_style.rich_style
1652
+ line_filters = self.get_line_filters()
1653
+ _base_background, background = self.background_colors
1654
+ strips: list[Strip] = []
1655
+ for y in crop.line_range:
1656
+ strip = self.render_line(y)
1657
+ if strip.cell_length != content_width:
1658
+ strip = strip.adjust_cell_length(content_width, rich_style)
1659
+ for line_filter in line_filters:
1660
+ strip = strip.apply_filter(line_filter, background)
1661
+ if hover_link_style:
1662
+ strip = strip.style_links(self.hover_style.link_id, hover_link_style)
1663
+ if crop.column_span != (0, content_width):
1664
+ strip = strip.crop(crop.x, crop.x + crop.width)
1665
+ strips.append(strip)
1666
+ return strips
1667
+
1668
+ def get_content_width(self, container: Size, viewport: Size) -> int:
1669
+ return self.virtual_size.width
1670
+
1671
+ def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
1672
+ if self._blocks and width > 0 and self._width_at_last_layout != width:
1673
+ self._layout_blocks(width)
1674
+ return self.virtual_size.height
1675
+
1676
+ def update(self, markdown: str) -> AwaitComplete:
1677
+ """Update the document with new Markdown.
1678
+
1679
+ Updates coalesce: until the scheduled parse starts, further
1680
+ ``update()``/``append()`` calls join it, and it parses the source as it
1681
+ stands when it starts. A burst of cumulative streamed updates therefore
1682
+ costs one parse and one layout, not one per call.
1683
+
1684
+ Args:
1685
+ markdown: A string containing Markdown.
1686
+
1687
+ Returns:
1688
+ An optionally awaitable object that resolves once this markdown, or
1689
+ newer, is installed. Cancelling one awaiter leaves the shared parse
1690
+ running for the others.
1691
+ """
1692
+ self._markdown = markdown
1693
+ return self._request_parse(announce_contents=True)
1694
+
1695
+ def append(self, markdown: str) -> AwaitComplete:
1696
+ """Append markdown to the document.
1697
+
1698
+ Coalesces with pending updates as ``update()`` does.
1699
+
1700
+ Args:
1701
+ markdown: A fragment of markdown to be appended.
1702
+
1703
+ Returns:
1704
+ An optionally awaitable object that resolves once the document with
1705
+ this fragment, or newer, is installed.
1706
+ """
1707
+ self._markdown = self.source + markdown
1708
+ return self._request_parse(announce_contents=False)
1709
+
1710
+ def _request_parse(self, *, announce_contents: bool) -> AwaitComplete:
1711
+ """Join the pending parse of ``_markdown``, or schedule one.
1712
+
1713
+ ``TableOfContentsUpdated`` follows the parse when any joined request
1714
+ set ``announce_contents`` (every ``update()`` does) or when the parsed
1715
+ document has headings, so a group of ``append()`` calls announces only
1716
+ what one of them alone would have.
1717
+ """
1718
+ self._table_of_contents = None
1719
+ pending = self._pending_parse
1720
+ # A pending task is done here only when it was cancelled before it read
1721
+ # the source (loop shutdown); it can no longer parse, so it is not joinable.
1722
+ if pending is not None and not pending.task.done():
1723
+ pending.announce_contents = pending.announce_contents or announce_contents
1724
+ return AwaitComplete(asyncio.shield(pending.task))
1725
+
1726
+ # Drop the old document's render caches once per parse; installing the
1727
+ # new blocks clears them again.
1728
+ self._line_cache.clear()
1729
+ self._table_layouts.clear()
1730
+ self._table_strips.clear()
1731
+ self._fence_layouts.clear()
1732
+ self._fence_line_strips.clear()
1733
+ self._fence_source_line_strips.clear()
1734
+ self._block_strips.clear()
1735
+ self._block_style_cache.clear()
1736
+ self._plain_selection_cache = None
1737
+ self._lines_cache_result = None
1738
+ pending = _PendingParse(self._run_parse, announce_contents=announce_contents)
1739
+ self._pending_parse = pending
1740
+ return AwaitComplete(asyncio.shield(pending.task))
1741
+
1742
+ async def _run_parse(self, request: _PendingParse) -> None:
1743
+ """Parse and install the current source once the lock is free."""
1744
+ async with self.lock:
1745
+ # Reading the source ends the join window: a later request is
1746
+ # newer than this parse's text and schedules a parse of its own.
1747
+ if self._pending_parse is request:
1748
+ self._pending_parse = None
1749
+ markdown = self._markdown
1750
+ blocks = await asyncio.get_running_loop().run_in_executor(None, self._build_blocks, markdown)
1751
+ self._install_blocks(blocks)
1752
+
1753
+ lines = markdown.splitlines()
1754
+ self._last_parsed_line = len(lines) - (1 if lines and lines[-1] else 0)
1755
+ self.refresh()
1756
+ if request.announce_contents or any(block.block_type == "heading" for block in blocks):
1757
+ self.post_message(
1758
+ VirtualizedMarkdown.TableOfContentsUpdated(self, self.table_of_contents).set_sender(self)
1759
+ )
1760
+
1761
+ def _install_blocks(self, blocks: list[MarkdownBlock]) -> None:
1762
+ """Commit freshly parsed blocks and lay them out.
1763
+
1764
+ The heading list is dropped here as well as when the update starts: an
1765
+ earlier update still holding the lock rebuilds it from its own blocks
1766
+ after this update's start cleared it, and ``_layout_blocks`` does not
1767
+ touch it.
1768
+
1769
+ A widget that has never been arranged (just mounted, or mounted inside
1770
+ a hidden container) has no content width yet. Laying out now would use
1771
+ the placeholder width that the first arrange immediately lays out
1772
+ again, so the layout waits for the real width instead:
1773
+ ``get_content_height`` supplies it on the first arrange, and
1774
+ ``on_resize`` and ``render_line`` converge on it otherwise. Those skip
1775
+ a widget without blocks, so an empty document, zero lines at any
1776
+ width, is laid out at once.
1777
+ """
1778
+ self._blocks = blocks
1779
+ self._table_of_contents = None
1780
+ if blocks and self.scrollable_content_region.width <= 0:
1781
+ self._defer_layout_until_arranged()
1782
+ return
1783
+ self._layout_blocks()
1784
+
1785
+ def _defer_layout_until_arranged(self) -> None:
1786
+ """Drop the layout of replaced blocks and request a measuring pass.
1787
+
1788
+ Everything cleared here indexes blocks by position, so none of it may
1789
+ outlive the blocks it was computed from. ``virtual_size`` keeps its
1790
+ last value (the constructor's line estimate for a fresh widget), so
1791
+ the parent never measures a zero-height transcript row meanwhile.
1792
+ """
1793
+ self._width_at_last_layout = 0
1794
+ self._total_lines = 0
1795
+ self._block_line_info.clear()
1796
+ self._diagram_action_lines.clear()
1797
+ self._diagram_action_widths.clear()
1798
+ self._plain_selection_cache = None
1799
+ self._lines_cache_result = None
1800
+ self.refresh(layout=True)
1801
+
1802
+ def scroll_to_block_id(self, block_id: str) -> None:
1803
+ """Scroll to a block by its ID.
1804
+
1805
+ Args:
1806
+ block_id: The block ID to scroll to.
1807
+ """
1808
+ for info in self._block_line_info:
1809
+ block = self._blocks[info.block_index]
1810
+ if block.block_id == block_id:
1811
+ self.scroll_to(y=info.start_line, animate=False)
1812
+ return
1813
+
1814
+ def selection_updated(self, selection: Selection | None) -> None:
1815
+ """Refresh display when selection changes (highlight is post-cache)."""
1816
+ self._plain_selection_cache = None
1817
+ self._lines_cache_result = None
1818
+ self.refresh()
1819
+
1820
+ def _build_plain_selection_lines(self) -> tuple[list[str], list[tuple[str, int]]]:
1821
+ """Build plain text rows and copy separators from rendered strips.
1822
+
1823
+ Renders every virtual line (using cache where available) and
1824
+ extracts plain text. This ensures indentation, prefixes, borders,
1825
+ padding, and word-wrapping match the screen exactly.
1826
+
1827
+ The companion separator list records how adjacent rendered rows should
1828
+ be joined when copied. Textual selection is visual-row based, so a
1829
+ soft-wrapped paragraph row would otherwise become a hard ``\n`` in the
1830
+ clipboard. Markdown source line ends still copy as newlines.
1831
+ """
1832
+ width = self._width_at_last_layout
1833
+ if width <= 0 or self._total_lines == 0:
1834
+ return [], []
1835
+
1836
+ lines: list[str] = []
1837
+ formatted_cache: dict[int, list[Any]] = {}
1838
+ for y in range(self._total_lines):
1839
+ cache_key = (y, width)
1840
+ cached = self._line_cache.get(cache_key)
1841
+ if cached is None:
1842
+ result = self._find_block_at_line(y)
1843
+ if result is None:
1844
+ cached = Strip.blank(width, self.visual_style.rich_style)
1845
+ else:
1846
+ _, info = result
1847
+ block = self._blocks[info.block_index]
1848
+ cached = self._render_block_line(block, info, y, width)
1849
+ self._line_cache[cache_key] = cached
1850
+ lines.append(_strip_plain_text(cached).rstrip())
1851
+ separators = [
1852
+ self._selection_separator_after_line(y, width, formatted_cache, lines[y + 1])
1853
+ for y in range(self._total_lines - 1)
1854
+ ]
1855
+ return lines, separators
1856
+
1857
+ def _selection_separator_after_line(
1858
+ self,
1859
+ line: int,
1860
+ width: int,
1861
+ formatted_cache: dict[int, list[Any]],
1862
+ following_visual_line: str,
1863
+ ) -> tuple[str, int]:
1864
+ """Return the clipboard separator and next-row prefix skip after a rendered virtual line."""
1865
+ current = self._find_block_at_line(line)
1866
+ following = self._find_block_at_line(line + 1)
1867
+ if current is None or following is None:
1868
+ return "\n", 0
1869
+
1870
+ _current_index, current_info = current
1871
+ _following_index, following_info = following
1872
+ if current_info.block_index != following_info.block_index:
1873
+ return "\n", 0
1874
+
1875
+ block = self._blocks[current_info.block_index]
1876
+ if block.block_type in {"diagram", "hr", "table"} or current_info.block_index in self._math_rows:
1877
+ return "\n", 0
1878
+
1879
+ current_content_line = self._actual_content_line_index(block, current_info, line)
1880
+ following_content_line = self._actual_content_line_index(block, following_info, line + 1)
1881
+ if current_content_line is None or following_content_line is None:
1882
+ return "\n", 0
1883
+
1884
+ formatted_lines = self._formatted_lines_for_selection(block, current_info, width, formatted_cache)
1885
+ if current_content_line >= len(formatted_lines) or following_content_line >= len(formatted_lines):
1886
+ return "\n", 0
1887
+
1888
+ current_formatted = formatted_lines[current_content_line]
1889
+ following_formatted = formatted_lines[following_content_line]
1890
+ if current_formatted.line_end or current_formatted.y != following_formatted.y:
1891
+ return "\n", 0
1892
+ separator = self._soft_wrap_separator(block, current_formatted, following_formatted)
1893
+ return separator, self._soft_wrap_prefix_skip(following_formatted, following_visual_line)
1894
+
1895
+ def _actual_content_line_index(
1896
+ self,
1897
+ block: MarkdownBlock,
1898
+ info: _BlockLineInfo,
1899
+ line: int,
1900
+ ) -> int | None:
1901
+ """Map a virtual line to the block content line, excluding margins/padding."""
1902
+ local_line = line - info.start_line
1903
+ if local_line < info.top_margin:
1904
+ return None
1905
+
1906
+ content_end = info.top_margin + info.content_height
1907
+ if local_line >= content_end:
1908
+ return None
1909
+
1910
+ content_line = local_line - info.top_margin
1911
+ if content_line < block.padding_top:
1912
+ return None
1913
+
1914
+ actual_content_end = info.content_height - block.padding_bottom
1915
+ if content_line >= actual_content_end:
1916
+ return None
1917
+
1918
+ return content_line - block.padding_top
1919
+
1920
+ def _formatted_lines_for_selection(
1921
+ self,
1922
+ block: MarkdownBlock,
1923
+ info: _BlockLineInfo,
1924
+ width: int,
1925
+ formatted_cache: dict[int, list[Any]],
1926
+ ) -> list[Any]:
1927
+ """Return Textual formatted content lines for source-line boundary checks."""
1928
+ cached = formatted_cache.get(info.block_index)
1929
+ if cached is not None:
1930
+ return cached
1931
+
1932
+ content_width = self._content_width_for_block(block, width)
1933
+ rules = self.styles.get_render_rules()
1934
+ get_rule = rules.get
1935
+ lines = block.content._wrap_and_format(
1936
+ content_width,
1937
+ align=get_rule("text_align", "left"),
1938
+ overflow=get_rule("text_overflow", "fold"),
1939
+ no_wrap=get_rule("text_wrap", "wrap") == "nowrap",
1940
+ line_pad=get_rule("line_pad", 0),
1941
+ tab_size=8,
1942
+ get_style=self._get_style,
1943
+ )
1944
+ formatted_cache[info.block_index] = lines
1945
+ return lines
1946
+
1947
+ def _content_width_for_block(self, block: MarkdownBlock, width: int) -> int:
1948
+ """Return the content cell width used by ``_render_block_line``."""
1949
+ border_width = len(block.border_left) if block.border_left else 0
1950
+ content_width = width - block.indent - block.padding_left - block.padding_right - border_width - 1
1951
+ return max(1, content_width)
1952
+
1953
+ @staticmethod
1954
+ def _soft_wrap_separator(block: MarkdownBlock, current_line: Any, following_line: Any) -> str:
1955
+ """Return source whitespace removed between two visual wrap rows."""
1956
+ source_lines = block.content.plain.split("\n")
1957
+ source_line_index = current_line.y
1958
+ if source_line_index < 0 or source_line_index >= len(source_lines):
1959
+ return " "
1960
+
1961
+ source = source_lines[source_line_index]
1962
+ source_offsets = current_line.source_offsets
1963
+ if source_offsets is not None:
1964
+ rendered_end = min(len(current_line.plain), len(source_offsets) - 1)
1965
+ current_source_end = source_offsets[rendered_end]
1966
+ else:
1967
+ current_source_end = current_line.x + len(current_line.plain)
1968
+ following_source_offsets = following_line.source_offsets
1969
+ following_source_start = (
1970
+ following_source_offsets[0] if following_source_offsets is not None else following_line.x
1971
+ )
1972
+ return source[current_source_end:following_source_start]
1973
+
1974
+ @staticmethod
1975
+ def _soft_wrap_prefix_skip(following_line: Any, following_visual_line: str) -> int:
1976
+ """Return visual-only prefix length to drop from a soft-wrapped continuation row."""
1977
+ content_text = following_line.plain.rstrip()
1978
+ if not content_text or not following_visual_line.endswith(content_text):
1979
+ return 0
1980
+ return len(following_visual_line) - len(content_text)
1981
+
1982
+ def _extract_selection_from_plain_lines(
1983
+ self,
1984
+ selection: Selection,
1985
+ lines: list[str],
1986
+ separators: list[tuple[str, int]],
1987
+ ) -> str:
1988
+ """Extract selected text while honoring per-row clipboard separators."""
1989
+ if not lines:
1990
+ return ""
1991
+
1992
+ if selection.start is None:
1993
+ start_line_index = 0
1994
+ start_offset = 0
1995
+ else:
1996
+ start_line_index, start_offset = selection.start.transpose
1997
+
1998
+ if selection.end is None:
1999
+ end_line_index = len(lines) - 1
2000
+ end_offset = len(lines[-1])
2001
+ else:
2002
+ end_line_index, end_offset = selection.end.transpose
2003
+
2004
+ if start_line_index < 0 or start_line_index >= len(lines):
2005
+ return ""
2006
+ end_line_index = min(len(lines) - 1, end_line_index)
2007
+
2008
+ if start_line_index == end_line_index:
2009
+ return lines[start_line_index][start_offset:end_offset]
2010
+
2011
+ chunks: list[str] = []
2012
+ for line_index in range(start_line_index, end_line_index + 1):
2013
+ line = lines[line_index]
2014
+ start = start_offset if line_index == start_line_index else 0
2015
+ end = end_offset if line_index == end_line_index else len(line)
2016
+ if line_index > start_line_index:
2017
+ _separator, prefix_skip = separators[line_index - 1]
2018
+ if start == 0:
2019
+ start = prefix_skip
2020
+ end = max(start, end)
2021
+ chunks.append(line[start:end])
2022
+ if line_index < end_line_index:
2023
+ separator, _prefix_skip = separators[line_index]
2024
+ chunks.append(separator)
2025
+ return "".join(chunks)
2026
+
2027
+ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
2028
+ """Extract selected text from rendered strips.
2029
+
2030
+ Builds plain text from the actual rendered strips so that character
2031
+ positions (indentation, prefixes, borders, wrapping) match the
2032
+ screen layout exactly.
2033
+ """
2034
+ if self._total_lines == 0:
2035
+ return None
2036
+ if self._plain_selection_cache is None:
2037
+ self._plain_selection_cache = self._build_plain_selection_lines()
2038
+ lines, separators = self._plain_selection_cache
2039
+ extracted = self._extract_selection_from_plain_lines(selection, lines, separators)
2040
+ if not extracted:
2041
+ return None
2042
+ return extracted, "\n"
2043
+
2044
+ async def action_link(self, href: str) -> None:
2045
+ """Called on link click."""
2046
+ self.post_message(VirtualizedMarkdown.LinkClicked(self, href))