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,2478 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """Tool call widgets — ToolCall (individual) and ToolGroup (collapsible container).
4
+
5
+ ToolGroup groups consecutive tool calls within a single agent turn.
6
+ Parallel tool calls appear together, each with its own spinner/status.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import re
15
+ import time
16
+ from collections.abc import Callable, Sequence
17
+ from contextlib import suppress
18
+ from dataclasses import dataclass, field
19
+ from functools import partial
20
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, cast, runtime_checkable
21
+
22
+ from rich.style import Style
23
+ from rich.text import Text
24
+ from textual.containers import Horizontal, Vertical
25
+ from textual.css.query import NoMatches
26
+ from textual.message import Message
27
+ from textual.reactive import reactive
28
+ from textual.selection import Selection
29
+ from textual.timer import Timer
30
+ from textual.widget import Widget
31
+ from textual.widgets import Static
32
+
33
+ from chrys.app.tui.clipboard import OSC52_COPY_MAX_BYTES, copy_text_to_clipboards, terminal_clipboard_size
34
+ from chrys.app.tui.copy_messages import COPIED_TITLE
35
+ from chrys.app.tui.i18n import render_str, render_text, widget_localizer
36
+ from chrys.app.tui.support.gc_freeze import (
37
+ GcAbsorbReason,
38
+ GcAbsorbRequested,
39
+ GcReclaimReason,
40
+ GcReclaimRequested,
41
+ GcRemovedContent,
42
+ current_gc_freeze_epoch,
43
+ )
44
+ from chrys.app.tui.util.static_update import update_static_in_place
45
+ from chrys.app.tui.util.visibility import is_widget_shown_on_active_screen
46
+ from chrys.app.tui.widgets.chat.file_snapshot import FileSnapshotPayload
47
+ from chrys.app.tui.widgets.chat.tool_view_builders import (
48
+ TOOL_VIEW_EMPTY,
49
+ TOOL_VIEW_OUTPUT,
50
+ ToolViewImage,
51
+ build_code_view,
52
+ build_output_views,
53
+ build_params_view,
54
+ )
55
+ from chrys.app.tui.widgets.click_affordance import ClickAffordance
56
+ from chrys.app.tui.widgets.loading import ChrysLoadingIndicator
57
+ from chrys.foundation.i18n import MessageRef, msg
58
+ from chrys.foundation.i18n.formatting import format_message, sanitize_legacy_block, sanitize_legacy_scalar
59
+ from chrys.foundation.patches.textual_precompose import precompose_tree
60
+ from chrys.foundation.platform.files import surrogate_safe_text
61
+ from chrys.foundation.tool_kinds import (
62
+ KIND_ASK_USER,
63
+ KIND_FILESYSTEM_READ,
64
+ KIND_FILESYSTEM_WRITE,
65
+ KIND_SEARCH,
66
+ KIND_SHELL,
67
+ KIND_SKILL,
68
+ KIND_SUB_AGENT,
69
+ )
70
+ from chrys.foundation.tool_result_metadata import (
71
+ legacy_error_text_applies,
72
+ result_text_exit_code,
73
+ shell_exit_code_from_metadata,
74
+ shell_timed_out_from_metadata,
75
+ tool_result_metadata_failure_state,
76
+ tool_result_metadata_is_rejected,
77
+ )
78
+
79
+ # Protocol attributes expected on any tool widget (BaseToolCard or custom renderer):
80
+ # call_id, tool_name, status, result_text
81
+ # set_complete(result, duration_ms), set_error(error), update_args(args)
82
+
83
+ ToolRenderStatus = Literal["complete", "error", "rejected"]
84
+ logger = logging.getLogger(__name__)
85
+
86
+ _TOOL_DETAILS_COPIED = msg(
87
+ "tui.tool.copy.details",
88
+ fallback="Copied {tool_name} execution details",
89
+ )
90
+ _TOOL_DETAILS_COPIED_TOO_LARGE = msg(
91
+ "tui.tool.copy.details_too_large",
92
+ fallback="Copied {tool_name} execution details (terminal clipboard skipped: payload too large)",
93
+ )
94
+ _TOOL_DETAILS_COPIED_UNAVAILABLE = msg(
95
+ "tui.tool.copy.details_unavailable",
96
+ fallback="Copied {tool_name} execution details (terminal clipboard unavailable)",
97
+ )
98
+
99
+ TOOL_CARD_ACTION_VIEW = msg("tui.tool_card.action.view", fallback="view")
100
+ TOOL_CARD_ACTION_COPY = msg("tui.tool_card.action.copy", fallback="copy")
101
+ TOOL_CARD_ACTION_VIEW_TOOLTIP = msg(
102
+ "tui.tool_card.action.view_tooltip",
103
+ fallback="View full tool input and output",
104
+ )
105
+ TOOL_CARD_ACTION_COPY_TOOLTIP = msg(
106
+ "tui.tool_card.action.copy_tooltip",
107
+ fallback="Copy tool execution details",
108
+ )
109
+ TOOL_CARD_RUNNING = msg("tui.tool_card.status.running", fallback="running")
110
+ TOOL_CARD_APPROVED = msg("tui.tool_card.status.approved", fallback="approved")
111
+ TOOL_CARD_REJECTED = msg("tui.tool_card.status.rejected", fallback="Rejected")
112
+ TOOL_CARD_ERRORED = msg("tui.tool_card.status.errored", fallback="Errored")
113
+ TOOL_CARD_ERRORED_WITH_CODE = msg(
114
+ "tui.tool_card.status.errored_with_code",
115
+ fallback="Errored {code}",
116
+ )
117
+ TOOL_CARD_COMPLETED = msg("tui.tool_card.status.completed", fallback="Completed")
118
+ TOOL_CARD_INTERRUPTED = msg("tui.tool_card.status.interrupted", fallback="Interrupted")
119
+ TOOL_CARD_SKIPPED = msg("tui.tool_card.status.skipped", fallback="Skipped")
120
+ _TOOL_CARD_IMAGE_RESOLUTION = msg(
121
+ "tui.tool_card.image.resolution",
122
+ fallback="Resolution: {width}x{height}",
123
+ )
124
+ _TOOL_CARD_IMAGE_TYPE = msg("tui.tool_card.image.type", fallback="Type: {media_type}")
125
+ _TOOL_CARD_GROUP_TITLE = msg(
126
+ "tui.tool_card.group.title",
127
+ fallback="Tools ({done}/{total})",
128
+ )
129
+ _TOOL_CARD_GROUP_TITLE_TIMED = msg(
130
+ "tui.tool_card.group.title_timed",
131
+ fallback="Tools ({done}/{total}, {duration})",
132
+ )
133
+ _TOOL_ACTIVITY_RUNNING = msg("tui.tool_card.group.activity.running", fallback="Running {tool}")
134
+ _TOOL_ACTIVITY_SHELL = msg("tui.tool_card.group.activity.shell", fallback="Running shell command")
135
+ _TOOL_ACTIVITY_READING = msg("tui.tool_card.group.activity.reading", fallback="Reading {path}")
136
+ _TOOL_ACTIVITY_WRITING = msg("tui.tool_card.group.activity.writing", fallback="Writing {path}")
137
+ _TOOL_ACTIVITY_EDITING = msg("tui.tool_card.group.activity.editing", fallback="Editing {path}")
138
+ _TOOL_ACTIVITY_SEARCHING = msg("tui.tool_card.group.activity.searching", fallback="Searching for {pattern}")
139
+ _TOOL_ACTIVITY_FINDING = msg("tui.tool_card.group.activity.finding", fallback="Finding {pattern}")
140
+ _TOOL_ACTIVITY_LOADING_SKILL = msg("tui.tool_card.group.activity.loading_skill", fallback="Loading skill {skill}")
141
+ _TOOL_ACTIVITY_READING_SKILL = msg(
142
+ "tui.tool_card.group.activity.reading_skill",
143
+ fallback="Reading skill resource {resource}",
144
+ )
145
+ _TOOL_ACTIVITY_RUNNING_SKILL = msg(
146
+ "tui.tool_card.group.activity.running_skill",
147
+ fallback="Running skill script {script}",
148
+ )
149
+ _TOOL_ACTIVITY_WAITING_FOR_ANSWER = msg(
150
+ "tui.tool_card.group.activity.waiting_for_answer",
151
+ fallback="Waiting for your answer",
152
+ )
153
+ _TOOL_ACTIVITY_RUNNING_AGENT = msg("tui.tool_card.group.activity.running_agent", fallback="Running {agent}")
154
+ _TOOL_ACTIVITY_AGENT_DETAIL = msg(
155
+ "tui.tool_card.group.activity.agent_detail",
156
+ fallback="Running {agent}: {activity}",
157
+ )
158
+
159
+
160
+ @runtime_checkable
161
+ class _ToolArgsUpdatable(Protocol):
162
+ def update_args(self, args: dict[str, Any]) -> None:
163
+ """Refresh the displayed arguments for a mounted tool widget."""
164
+
165
+
166
+ @runtime_checkable
167
+ class _ToolProgressUpdatable(Protocol):
168
+ def update_progress(self, lines: list[str]) -> None:
169
+ """Refresh the displayed streaming progress for a mounted tool widget."""
170
+
171
+
172
+ @runtime_checkable
173
+ class _HostedStatusUpdatable(Protocol):
174
+ def update_hosted_status(self, status: str, provider_status: str) -> None:
175
+ """Refresh provider-hosted lifecycle text."""
176
+
177
+
178
+ @runtime_checkable
179
+ class _ToolDisplayStateSerializable(Protocol):
180
+ def compact_display_state(self) -> dict[str, Any] | None:
181
+ """Return compact renderer-specific state needed to rebuild the visible card."""
182
+
183
+
184
+ @runtime_checkable
185
+ class _ToolDisplayStateRestorable(Protocol):
186
+ def restore_compact_display_state(self, state: dict[str, Any]) -> None:
187
+ """Restore compact renderer-specific state before terminal rendering."""
188
+
189
+
190
+ @runtime_checkable
191
+ class _ToolErrorPayloadRestorable(Protocol):
192
+ def restore_error_payload(
193
+ self,
194
+ error: str,
195
+ *,
196
+ image_contents: list[Any],
197
+ metadata: dict[str, Any] | None,
198
+ artifacts: list[dict[str, Any]],
199
+ ) -> None:
200
+ """Restore structured output retained before a terminal failure."""
201
+
202
+
203
+ @runtime_checkable
204
+ class _ToolPendingDiffMountable(Protocol):
205
+ async def mount_diff_if_pending(self) -> bool:
206
+ """Mount an expensive inline diff that was deferred while collapsed."""
207
+
208
+
209
+ @runtime_checkable
210
+ class _ToolPendingContentMountable(Protocol):
211
+ async def mount_pending_content(self) -> bool:
212
+ """Mount expensive inline content that was deferred while collapsed."""
213
+
214
+
215
+ @runtime_checkable
216
+ class _ToolCollapsedContentReleasable(Protocol):
217
+ def release_collapsed_content(self) -> None:
218
+ """Release expensive inline content after the parent group collapses."""
219
+
220
+
221
+ class _CompletableToolRenderer(Protocol):
222
+ """Completion surface required from every registered tool renderer."""
223
+
224
+ status: str
225
+
226
+ def set_complete(self, result: str, duration_ms: int = 0, **kwargs: Any) -> None: ...
227
+
228
+ def set_error(self, error: str) -> None: ...
229
+
230
+
231
+ def _completion_renderer(widget: Widget) -> _CompletableToolRenderer:
232
+ """View a registered Widget through the renderer's trusted structural contract."""
233
+ return cast("_CompletableToolRenderer", widget)
234
+
235
+
236
+ class ToolViewRequested(Message):
237
+ """Request that the owning screen open a detailed tool-view modal."""
238
+
239
+ def __init__(
240
+ self,
241
+ *,
242
+ title: str,
243
+ input_widgets: list[Widget],
244
+ output_widgets: list[Widget],
245
+ raw_input: str = "",
246
+ raw_output: str = "",
247
+ copy_enabled: bool = True,
248
+ initial_tab: str = "input",
249
+ ) -> None:
250
+ super().__init__()
251
+ self.title = title
252
+ self.input_widgets = input_widgets
253
+ self.output_widgets = output_widgets
254
+ self.raw_input = raw_input
255
+ self.raw_output = raw_output
256
+ self.copy_enabled = copy_enabled
257
+ self.initial_tab = initial_tab
258
+
259
+
260
+ @dataclass
261
+ class _NestedToolActivity:
262
+ """Latest presentation state for one tool running inside a sub-agent."""
263
+
264
+ tool_name: str
265
+ tool_kind: str
266
+ args: dict[str, Any]
267
+
268
+
269
+ @dataclass
270
+ class _ToolRecord:
271
+ """Minimal state needed to rebuild a completed collapsed tool widget."""
272
+
273
+ call_id: str
274
+ tool_name: str
275
+ tool_kind: str
276
+ args_summary: str
277
+ args: dict[str, Any] | None
278
+ status: str = "running"
279
+ result: str = ""
280
+ duration_ms: int = 0
281
+ duration_known: bool = False
282
+ timestamp: str = ""
283
+ replay_timing: bool = False
284
+ file_snapshot: FileSnapshotPayload | None = None
285
+ approval: str | None = None
286
+ metadata: dict[str, Any] | None = None
287
+ image_contents: list[Any] = field(default_factory=list, repr=False, compare=False)
288
+ lazy: bool = False
289
+ display_state: dict[str, Any] | None = None
290
+ completed_via_result: bool = False
291
+ provider_hosted: bool = False
292
+ hosted_family: str = ""
293
+ provider: str = ""
294
+ provider_item_type: str = ""
295
+ provider_status: str = ""
296
+ provider_call_id: str = ""
297
+ canonical_status: str = "running"
298
+ artifacts: list[dict[str, Any]] = field(default_factory=list)
299
+ activity_order: int = 0
300
+ nested_activities: dict[str, _NestedToolActivity] = field(default_factory=dict)
301
+ nested_latest_activity: _ToolActivity | None = None
302
+
303
+
304
+ def _activity_arg(args: dict[str, Any] | None, key: str, *, path: bool = False) -> str:
305
+ """Return one single-line, markup-safe activity argument."""
306
+ if not args or not isinstance(value := args.get(key), str) or not value:
307
+ return ""
308
+ if path:
309
+ value = surrogate_safe_text(value)
310
+ return sanitize_legacy_scalar(value)
311
+
312
+
313
+ type _ToolActivity = MessageRef | str
314
+
315
+
316
+ _ACTIVITY_ARG_KEYS = frozenset(
317
+ {
318
+ "agent",
319
+ "agent_name",
320
+ "path",
321
+ "pattern",
322
+ "reason",
323
+ "resource_name",
324
+ "script_name",
325
+ "skill_name",
326
+ }
327
+ )
328
+
329
+
330
+ def _compact_activity_args(args: dict[str, Any] | None) -> dict[str, Any]:
331
+ """Keep only string fields that can appear in an activity title."""
332
+ if not args:
333
+ return {}
334
+ return {key: value for key, value in args.items() if key in _ACTIVITY_ARG_KEYS and isinstance(value, str)}
335
+
336
+
337
+ def _tool_activity_reference(tool_name: str, tool_kind: str, args: dict[str, Any] | None) -> _ToolActivity:
338
+ """Build localized activity prose for a running tool record."""
339
+ safe_name = sanitize_legacy_scalar(tool_name)
340
+ if tool_kind == KIND_SHELL:
341
+ if reason := _activity_arg(args, "reason"):
342
+ return reason
343
+ return _TOOL_ACTIVITY_SHELL.bind()
344
+ if tool_kind == KIND_FILESYSTEM_READ and (path := _activity_arg(args, "path", path=True)):
345
+ return _TOOL_ACTIVITY_READING.bind(path=path)
346
+ if tool_kind == KIND_FILESYSTEM_WRITE and (path := _activity_arg(args, "path", path=True)):
347
+ reference = _TOOL_ACTIVITY_EDITING if tool_name == "edit_file" else _TOOL_ACTIVITY_WRITING
348
+ return reference.bind(path=path)
349
+ if tool_kind == KIND_SEARCH and (pattern := _activity_arg(args, "pattern")):
350
+ reference = _TOOL_ACTIVITY_FINDING if tool_name == "glob" else _TOOL_ACTIVITY_SEARCHING
351
+ return reference.bind(pattern=pattern)
352
+ if tool_kind == KIND_SKILL:
353
+ skill = _activity_arg(args, "skill_name")
354
+ if tool_name == "load_skill" and skill:
355
+ return _TOOL_ACTIVITY_LOADING_SKILL.bind(skill=skill)
356
+ if tool_name == "read_skill_resource":
357
+ resource = _activity_arg(args, "resource_name")
358
+ if skill and resource:
359
+ resource = f"{skill}/{resource}"
360
+ if resource := resource or skill:
361
+ return _TOOL_ACTIVITY_READING_SKILL.bind(resource=resource)
362
+ if tool_name == "run_skill_script":
363
+ script = _activity_arg(args, "script_name")
364
+ if skill and script:
365
+ script = f"{skill}/{script}"
366
+ if script := script or skill:
367
+ return _TOOL_ACTIVITY_RUNNING_SKILL.bind(script=script)
368
+ if tool_kind == KIND_ASK_USER:
369
+ return _TOOL_ACTIVITY_WAITING_FOR_ANSWER.bind()
370
+ if tool_kind == KIND_SUB_AGENT:
371
+ agent = _activity_arg(args, "agent_name") or _activity_arg(args, "agent") or safe_name
372
+ return _TOOL_ACTIVITY_RUNNING_AGENT.bind(agent=agent)
373
+ return _TOOL_ACTIVITY_RUNNING.bind(tool=safe_name)
374
+
375
+
376
+ def tool_activity_reference(tool_name: str, tool_kind: str, args: dict[str, Any] | None) -> _ToolActivity:
377
+ """Return the shared semantic activity used by group and nested-agent summaries."""
378
+ return _tool_activity_reference(tool_name, tool_kind, args)
379
+
380
+
381
+ def _record_activity_reference(record: _ToolRecord, render_reference: Callable[[MessageRef], str]) -> _ToolActivity:
382
+ """Return the most specific current activity for a top-level record."""
383
+ if record.tool_kind == KIND_SUB_AGENT and record.nested_latest_activity is not None:
384
+ nested_activity = record.nested_latest_activity
385
+ detail = nested_activity if isinstance(nested_activity, str) else render_reference(nested_activity)
386
+ agent = (
387
+ _activity_arg(record.args, "agent_name")
388
+ or _activity_arg(record.args, "agent")
389
+ or sanitize_legacy_scalar(record.tool_name)
390
+ )
391
+ return _TOOL_ACTIVITY_AGENT_DETAIL.bind(agent=agent, activity=detail)
392
+ return _tool_activity_reference(record.tool_name, record.tool_kind, record.args)
393
+
394
+
395
+ def _compact_tool_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
396
+ """Drop bulky snapshot payloads from metadata retained by collapsed tool records."""
397
+ if not metadata:
398
+ return metadata
399
+ compacted = dict(metadata)
400
+ compacted.pop("file_snapshot", None)
401
+ compacted.pop("shell_file_snapshots", None)
402
+ return compacted
403
+
404
+
405
+ def fmt_duration(ms: int) -> str:
406
+ """Format milliseconds as a compact human string: ``120ms``, ``25s``, ``1m 25s``, ``2h 3m``."""
407
+ if ms < 1000:
408
+ return f"{ms}ms"
409
+ seconds = ms // 1000
410
+ if seconds < 60:
411
+ return f"{seconds}s"
412
+ m, s = divmod(seconds, 60)
413
+ if m < 60:
414
+ return f"{m}m {s}s" if s else f"{m}m"
415
+ h, m = divmod(m, 60)
416
+ if m == 0:
417
+ return f"{h}h"
418
+ return f"{h}h {m}m"
419
+
420
+
421
+ _ARGS_DISPLAY_MAX = 120
422
+ """Max characters for tool call arguments display."""
423
+
424
+ _RESULT_DISPLAY_MAX = 250
425
+ """Max characters for tool call result display."""
426
+
427
+ _IMAGE_PLACEHOLDER_RE = re.compile(r"^\[image/[^]\s]+ image\]$")
428
+
429
+ _TERMINAL_TOOL_STATUSES = frozenset({"complete", "error", "rejected"})
430
+ _TERMINAL_CANONICAL_STATUSES = frozenset({"completed", "failed", "interrupted"})
431
+
432
+ _PENDING_CONTENT_MOUNT_CONCURRENCY = 4
433
+ """Maximum deferred tool contents prepared concurrently after expanding a group."""
434
+
435
+
436
+ if TYPE_CHECKING:
437
+ from textual.app import ComposeResult, RenderResult
438
+ from textual.events import Click, Leave, MouseMove
439
+ from textual.geometry import Offset
440
+ from textual.visual import VisualType
441
+
442
+ _ToolCopyExcludedBase = Widget
443
+ else:
444
+ _ToolCopyExcludedBase = object
445
+
446
+ TOOL_COPY_EXCLUDED_CLASS = "-copy-excluded"
447
+ """CSS class marking tool-renderer widgets whose text should not be copied."""
448
+
449
+ TOOL_COPY_INCLUDED_CLASS = "-copy-included"
450
+ """CSS class opting a subtree back into copy inside an excluded tool card."""
451
+
452
+
453
+ def _content_additional_properties(content: Any) -> dict[str, Any]:
454
+ if isinstance(content, dict):
455
+ extra = content.get("additional_properties")
456
+ else:
457
+ # Kernel Content is an external boundary for the TUI.
458
+ extra = getattr(content, "additional_properties", None)
459
+ return extra if isinstance(extra, dict) else {}
460
+
461
+
462
+ def _content_attr(content: Any, attr: str) -> Any:
463
+ if isinstance(content, dict):
464
+ return content.get(attr)
465
+ # Kernel Content is an external boundary for the TUI.
466
+ return getattr(content, attr, None)
467
+
468
+
469
+ def _positive_int(value: Any) -> int | None:
470
+ if isinstance(value, bool):
471
+ return None
472
+ if isinstance(value, int) and value > 0:
473
+ return value
474
+ return None
475
+
476
+
477
+ def _image_metadata_value(content: Any, key: str) -> Any:
478
+ extra = _content_additional_properties(content)
479
+ return extra.get(key) if key in extra else _content_attr(content, key)
480
+
481
+
482
+ def _image_result_display(result: str, image_contents: Sequence[Any] | None) -> str:
483
+ if not image_contents or not result:
484
+ return result
485
+
486
+ lines: list[str] = []
487
+ for line in result.splitlines():
488
+ stripped = line.strip()
489
+ if _IMAGE_PLACEHOLDER_RE.match(stripped):
490
+ continue
491
+ lines.append(line)
492
+ return "\n".join(lines).strip()
493
+
494
+
495
+ def result_text_is_error(result: str, *, exit_code_authoritative: bool = False) -> bool:
496
+ """Return true for tool result text that follows the UI error convention."""
497
+ if exit_code_authoritative:
498
+ exit_code = result_text_exit_code(result)
499
+ if exit_code is not None:
500
+ return exit_code != 0
501
+ return result.lstrip().startswith("Error:")
502
+
503
+
504
+ def tool_result_is_error(
505
+ result: str,
506
+ approval: str | None,
507
+ tool_kind: str = "",
508
+ metadata: dict[str, Any] | None = None,
509
+ tool_name: str = "",
510
+ ) -> bool:
511
+ return _tool_result_error_state(result, approval, tool_kind, metadata, tool_name)
512
+
513
+
514
+ def tool_result_render_status(
515
+ result: str,
516
+ approval: str | None,
517
+ tool_kind: str = "",
518
+ metadata: dict[str, Any] | None = None,
519
+ tool_name: str = "",
520
+ *,
521
+ parsed_error: bool = False,
522
+ parsed_error_overrides_structured_success: bool = False,
523
+ ) -> ToolRenderStatus:
524
+ """Return the terminal display status for a completed tool result."""
525
+ if tool_result_is_rejected(approval, metadata):
526
+ return "rejected"
527
+ if _tool_result_error_state(
528
+ result,
529
+ approval,
530
+ tool_kind,
531
+ metadata,
532
+ tool_name,
533
+ parsed_error=parsed_error,
534
+ parsed_error_overrides_structured_success=parsed_error_overrides_structured_success,
535
+ ):
536
+ return "error"
537
+ return "complete"
538
+
539
+
540
+ def _tool_result_error_state(
541
+ result: str,
542
+ approval: str | None,
543
+ tool_kind: str = "",
544
+ metadata: dict[str, Any] | None = None,
545
+ tool_name: str = "",
546
+ *,
547
+ parsed_error: bool = False,
548
+ parsed_error_overrides_structured_success: bool = False,
549
+ ) -> bool:
550
+ if tool_result_is_rejected(approval, metadata):
551
+ return False
552
+ metadata_state = tool_result_metadata_failure_state(metadata)
553
+ if metadata_state is True:
554
+ return True
555
+ if metadata_state is False:
556
+ return parsed_error and parsed_error_overrides_structured_success
557
+ if parsed_error:
558
+ return True
559
+ if tool_kind == KIND_SHELL:
560
+ exit_code = shell_exit_code_from_metadata(metadata)
561
+ if exit_code is not None:
562
+ return exit_code != 0
563
+ if shell_timed_out_from_metadata(metadata):
564
+ return True
565
+ if not legacy_error_text_applies(tool_kind, tool_name):
566
+ return False
567
+ return result_text_is_error(result, exit_code_authoritative=tool_kind == KIND_SHELL)
568
+
569
+
570
+ def tool_result_is_rejected(approval: str | None, metadata: dict[str, Any] | None = None) -> bool:
571
+ """Return true when a tool result should render with rejected styling."""
572
+ if approval == "user_rejected":
573
+ return True
574
+ return tool_result_metadata_is_rejected(metadata)
575
+
576
+
577
+ def _image_border_subtitle(
578
+ image_contents: list[Any] | None,
579
+ fallback_size: tuple[int, int] | None,
580
+ *,
581
+ render_message: Callable[[MessageRef], str] = format_message,
582
+ ) -> str:
583
+ if not image_contents:
584
+ return ""
585
+ first = image_contents[0]
586
+ width = _positive_int(_image_metadata_value(first, "width"))
587
+ height = _positive_int(_image_metadata_value(first, "height"))
588
+ if (width is None or height is None) and fallback_size is not None:
589
+ width, height = fallback_size
590
+ media_type = _image_metadata_value(first, "media_type") or _content_attr(first, "media_type")
591
+ if not isinstance(media_type, str):
592
+ media_type = ""
593
+
594
+ parts: list[str] = []
595
+ if width is not None and height is not None:
596
+ parts.append(render_message(_TOOL_CARD_IMAGE_RESOLUTION.bind(width=width, height=height)))
597
+ if media_type:
598
+ parts.append(render_message(_TOOL_CARD_IMAGE_TYPE.bind(media_type=sanitize_legacy_scalar(media_type))))
599
+ return " · ".join(parts)
600
+
601
+
602
+ class ToolCopyExcludedMixin(_ToolCopyExcludedBase):
603
+ """Mixin for tool UI that should be omitted from screen-level copy."""
604
+
605
+ ALLOW_SELECT: ClassVar[bool] = False
606
+ image_contents: tuple[Any, ...] | list[Any] = ()
607
+
608
+ if TYPE_CHECKING:
609
+ approval: str | None
610
+ args: dict[str, Any]
611
+ args_summary: str
612
+ call_id: str
613
+ duration_ms: int
614
+ metadata: dict[str, Any] | None
615
+ result_text: str
616
+ status: str
617
+ tool_kind: str
618
+ tool_name: str
619
+
620
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
621
+ super().__init__(*args, **kwargs)
622
+ self.add_class(TOOL_COPY_EXCLUDED_CLASS)
623
+
624
+ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
625
+ """Tool renderer chrome/results are visual context, not transcript text."""
626
+ return None
627
+
628
+ def _show_tool_copy_button(self) -> None:
629
+ """Reveal the copy and view affordances after a terminal state."""
630
+ with suppress(Exception):
631
+ self.query_one(ToolCardHeader).show_actions()
632
+
633
+ def _render_message(self, reference: MessageRef) -> str:
634
+ return render_str(widget_localizer(self), reference)
635
+
636
+ def _defer_tool_copy(self) -> bool:
637
+ """Return true when a subclass has scheduled copy handling itself."""
638
+ return False
639
+
640
+ def _defer_tool_view(self) -> bool:
641
+ """Return true when a subclass has scheduled view handling itself."""
642
+ return False
643
+
644
+ def _tool_copy_status(self) -> str:
645
+ """Return a stable terminal status label for the copy payload."""
646
+ result = self.result_text.strip()
647
+ if self.has_class("-rejected"):
648
+ return "rejected"
649
+ if result.lower() == "cancelled":
650
+ return "cancelled"
651
+ if self.status == "error" or self.has_class("-error"):
652
+ return "error"
653
+ if tool_result_is_error(result, self.approval, self.tool_kind, self.metadata, self.tool_name):
654
+ return "error"
655
+ if self.status == "complete" or self.has_class("-success") or self.has_class("-complete"):
656
+ return "completed"
657
+ return self.status
658
+
659
+ def _tool_copy_input(self) -> tuple[str, str]:
660
+ """Return ``(language, text)`` for the tool input section."""
661
+ if self.args:
662
+ return "json", json.dumps(self.args, indent=2, ensure_ascii=False, default=str)
663
+ if self.args_summary:
664
+ return "text", self.args_summary
665
+ return "json", "{}"
666
+
667
+ def _tool_input_args(self) -> dict[str, Any]:
668
+ """Return structured input args from live state or replay summary."""
669
+ if self.args:
670
+ return self.args
671
+ if not self.args_summary:
672
+ return {}
673
+ try:
674
+ parsed = json.loads(self.args_summary)
675
+ except TypeError, ValueError:
676
+ return {}
677
+ return parsed if isinstance(parsed, dict) else {}
678
+
679
+ def _tool_copy_sections(self) -> list[tuple[str, str, str]]:
680
+ """Return ``(title, language, text)`` output sections for the copy payload."""
681
+ return [
682
+ (
683
+ self._render_message(TOOL_VIEW_OUTPUT.bind()),
684
+ "text",
685
+ self.result_text or self._render_message(TOOL_VIEW_EMPTY.bind()),
686
+ )
687
+ ]
688
+
689
+ def format_tool_execution_copy(self) -> str:
690
+ """Format this tool invocation as a paste-friendly Markdown transcript."""
691
+ input_lang, input_text = self._tool_copy_input()
692
+ lines = [
693
+ f"# {self.tool_name}",
694
+ "",
695
+ f"- **Status:** `{self._tool_copy_status()}`",
696
+ ]
697
+ if self.duration_ms:
698
+ lines.append(f"- **Duration:** `{fmt_duration(self.duration_ms)}`")
699
+ if self.call_id:
700
+ lines.append(f"- **Call ID:** `{self.call_id}`")
701
+
702
+ lines.extend(["", "## Input", _fenced_block(input_lang, input_text)])
703
+ for title, language, text in self._tool_copy_sections():
704
+ lines.extend(
705
+ ["", f"## {title}", _fenced_block(language, text or self._render_message(TOOL_VIEW_EMPTY.bind()))]
706
+ )
707
+ return "\n".join(lines).rstrip() + "\n"
708
+
709
+ def copy_tool_execution(self) -> None:
710
+ """Copy full tool input/output to the OS clipboard, and OSC52 when small."""
711
+ payload = self.format_tool_execution_copy()
712
+
713
+ terminal_payload_too_large = terminal_clipboard_size(payload) > OSC52_COPY_MAX_BYTES
714
+ localizer = widget_localizer(self)
715
+ if copy_text_to_clipboards(self.app, payload, max_terminal_bytes=OSC52_COPY_MAX_BYTES):
716
+ self.notify(
717
+ render_str(localizer, _TOOL_DETAILS_COPIED.bind(tool_name=self.tool_name)),
718
+ title=render_str(localizer, COPIED_TITLE.bind()),
719
+ timeout=2,
720
+ markup=False,
721
+ )
722
+ elif terminal_payload_too_large:
723
+ self.notify(
724
+ render_str(localizer, _TOOL_DETAILS_COPIED_TOO_LARGE.bind(tool_name=self.tool_name)),
725
+ title=render_str(localizer, COPIED_TITLE.bind()),
726
+ timeout=3,
727
+ markup=False,
728
+ )
729
+ else:
730
+ self.notify(
731
+ render_str(localizer, _TOOL_DETAILS_COPIED_UNAVAILABLE.bind(tool_name=self.tool_name)),
732
+ title=render_str(localizer, COPIED_TITLE.bind()),
733
+ timeout=3,
734
+ markup=False,
735
+ )
736
+
737
+ def on_tool_copy_button_clicked(self, event: ToolCopyButton.Clicked) -> None:
738
+ """Handle clicks from the header copy affordance."""
739
+ event.stop()
740
+ if self._defer_tool_copy():
741
+ return
742
+ self.copy_tool_execution()
743
+
744
+ # --- View modal ---
745
+
746
+ def _tool_view_title(self) -> str:
747
+ """Return the modal title for the detailed view."""
748
+ return self.tool_name
749
+
750
+ def _view_dark(self) -> bool:
751
+ """Whether the active Textual theme is dark — drives syntax styling."""
752
+ try:
753
+ return self.app.current_theme.dark
754
+ except Exception:
755
+ return True
756
+
757
+ def _tool_view_input_widgets(self) -> list[Widget]:
758
+ """Build the Input tab content. Subclasses may override for richer views."""
759
+ dark = self._view_dark()
760
+ if self.args:
761
+ return build_params_view(self.args, dark=dark, render_message=self._render_message)
762
+ language, text = self._tool_copy_input()
763
+ return [build_code_view(language, text, dark=dark, render_message=self._render_message)]
764
+
765
+ def _tool_view_output_widgets(self) -> list[Widget]:
766
+ """Build the Output tab content. Subclasses may override for richer views."""
767
+ if self.image_contents:
768
+ widgets: list[Widget] = [ToolViewImage(self.image_contents)]
769
+ display = _image_result_display(self.result_text, self.image_contents)
770
+ if display:
771
+ widgets.extend(
772
+ build_output_views(
773
+ [(self._render_message(TOOL_VIEW_OUTPUT.bind()), "text", display)],
774
+ dark=self._view_dark(),
775
+ render_message=self._render_message,
776
+ )
777
+ )
778
+ return widgets
779
+ return build_output_views(
780
+ self._tool_copy_sections(),
781
+ dark=self._view_dark(),
782
+ render_message=self._render_message,
783
+ )
784
+
785
+ def _tool_view_raw_input(self) -> str:
786
+ """Return the raw Input payload to copy (JSON for structured args)."""
787
+ args = self._tool_input_args()
788
+ if args:
789
+ return json.dumps(args, indent=2, ensure_ascii=False, default=str)
790
+ return self._tool_copy_input()[1]
791
+
792
+ def _tool_view_raw_output(self) -> str:
793
+ """Return the raw Output payload to copy (concatenated section text)."""
794
+ sections = self._tool_copy_sections()
795
+ if len(sections) == 1:
796
+ return sections[0][2] or self._render_message(TOOL_VIEW_EMPTY.bind())
797
+ empty = self._render_message(TOOL_VIEW_EMPTY.bind())
798
+ return "\n\n".join(f"{title}\n{text or empty}" for title, _, text in sections)
799
+
800
+ def _tool_view_copy_enabled(self) -> bool:
801
+ """Whether the detail modal should expose its whole-payload copy binding."""
802
+ return True
803
+
804
+ def open_tool_view(self) -> None:
805
+ """Request a modal showing the full, untruncated tool input and output."""
806
+ self.post_message(
807
+ ToolViewRequested(
808
+ title=self._tool_view_title(),
809
+ input_widgets=self._tool_view_input_widgets(),
810
+ output_widgets=self._tool_view_output_widgets(),
811
+ raw_input=self._tool_view_raw_input(),
812
+ raw_output=self._tool_view_raw_output(),
813
+ copy_enabled=self._tool_view_copy_enabled(),
814
+ )
815
+ )
816
+
817
+ def on_tool_view_button_clicked(self, event: ToolViewButton.Clicked) -> None:
818
+ """Handle clicks from the header view affordance."""
819
+ event.stop()
820
+ if self._defer_tool_view():
821
+ return
822
+ self.open_tool_view()
823
+
824
+
825
+ class ToolViewButton(ClickAffordance):
826
+ """Legacy view affordance widget; retained as the ``Clicked`` message namespace.
827
+
828
+ :class:`ToolCardHeader` now renders the affordance text itself and posts
829
+ ``ToolViewButton.Clicked`` so existing ``on_tool_view_button_clicked``
830
+ handlers keep working unchanged.
831
+ """
832
+
833
+ class Clicked(Message):
834
+ """Posted when the view affordance is clicked."""
835
+
836
+ CLICK_MESSAGE = Clicked
837
+
838
+ def __init__(self) -> None:
839
+ super().__init__("view", classes="tool-view-btn")
840
+
841
+ def on_mount(self) -> None:
842
+ localizer = widget_localizer(self)
843
+ self.update(render_text(localizer, TOOL_CARD_ACTION_VIEW.bind()))
844
+ self.tooltip = render_str(localizer, TOOL_CARD_ACTION_VIEW_TOOLTIP.bind())
845
+
846
+
847
+ class ToolCopyButton(ClickAffordance):
848
+ """Legacy copy affordance widget; retained as the ``Clicked`` message namespace.
849
+
850
+ See :class:`ToolViewButton` — :class:`ToolCardHeader` posts
851
+ ``ToolCopyButton.Clicked`` for clicks on its copy zone.
852
+ """
853
+
854
+ class Clicked(Message):
855
+ """Posted when the copy affordance is clicked."""
856
+
857
+ CLICK_MESSAGE = Clicked
858
+
859
+ def __init__(self, *, tooltip_message: MessageRef | None = None) -> None:
860
+ super().__init__("copy", classes="tool-copy-btn")
861
+ self._tooltip_message = tooltip_message
862
+
863
+ def on_mount(self) -> None:
864
+ localizer = widget_localizer(self)
865
+ self.update(render_text(localizer, TOOL_CARD_ACTION_COPY.bind()))
866
+ tooltip = self._tooltip_message or TOOL_CARD_ACTION_COPY_TOOLTIP.bind()
867
+ self.tooltip = render_str(localizer, tooltip)
868
+
869
+
870
+ class ToolCardHeader(Static):
871
+ """Single-widget tool header: label plus right-aligned view/copy affordances.
872
+
873
+ Replaces the former five-widget row (``ToolHeader`` container, label
874
+ ``Static``, ``ToolViewButton``, ``ToolActionSeparator``,
875
+ ``ToolCopyButton``). Tool cards dominate long transcripts, and every
876
+ mounted widget pays per-frame arrange/compositor cost while visible, so
877
+ the header renders the whole row itself and reimplements the affordance
878
+ behaviors: zone hit-testing for clicks, per-zone hover styling and
879
+ tooltip, and a pointer cursor over the actions.
880
+
881
+ The widget subclasses ``Static`` and treats its content as the label, so
882
+ renderer code that does ``query_one("#x-label", Static).update(...)``
883
+ keeps working unchanged. Markup parsing is disabled: labels are Rich
884
+ ``Text`` (styling travels in spans), and a plain string — which may quote
885
+ model output containing ``[...]`` — must render literally instead of
886
+ raising ``MarkupError``.
887
+ """
888
+
889
+ ALLOW_SELECT = False
890
+
891
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
892
+ "toolcardheader--action",
893
+ "toolcardheader--action-hover",
894
+ }
895
+
896
+ DEFAULT_CSS = """
897
+ ToolCardHeader {
898
+ width: 100%;
899
+ height: auto;
900
+ }
901
+ ToolCardHeader > .toolcardheader--action {
902
+ color: $text-muted;
903
+ text-style: dim not bold;
904
+ }
905
+ ToolCardHeader > .toolcardheader--action-hover {
906
+ color: $accent;
907
+ text-style: underline not dim not bold;
908
+ }
909
+ """
910
+
911
+ # Rendered actions cell: " view / copy " — 1-cell pads mirror the CSS
912
+ # padding of the legacy affordance widgets.
913
+ _ACTION_ZONES: ClassVar[tuple[tuple[str, int, int], ...]] = (("view", 1, 5), ("copy", 8, 12))
914
+ _ACTIONS_WIDTH: ClassVar[int] = 13
915
+ _ZONE_TOOLTIPS: ClassVar[dict[str, MessageRef]] = {
916
+ "view": TOOL_CARD_ACTION_VIEW_TOOLTIP.bind(),
917
+ "copy": TOOL_CARD_ACTION_COPY_TOOLTIP.bind(),
918
+ }
919
+
920
+ def __init__(
921
+ self,
922
+ label: Text | str = "",
923
+ *,
924
+ id: str | None = None,
925
+ view_only_label: MessageRef | None = None,
926
+ ) -> None:
927
+ super().__init__(label, id=id, markup=False)
928
+ self.add_class(TOOL_COPY_EXCLUDED_CLASS)
929
+ self._action_mode: Literal["hidden", "view_only", "full"] = (
930
+ "view_only" if view_only_label is not None else "hidden"
931
+ )
932
+ self._hover_zone: str | None = None
933
+ self._view_label = TOOL_CARD_ACTION_VIEW.fallback
934
+ self._copy_label = TOOL_CARD_ACTION_COPY.fallback
935
+ self._view_only_message = view_only_label
936
+ self._view_only_label = format_message(view_only_label) if view_only_label is not None else ""
937
+
938
+ def on_mount(self) -> None:
939
+ self._resolve_action_labels()
940
+
941
+ def update(self, content: VisualType = "", *, layout: bool = True) -> None:
942
+ """Replace the label; relayout only when the new label changes the header's size.
943
+
944
+ Running cards rewrite their label with the elapsed time every second, and a
945
+ same-size label needs a repaint, not a reflow of the whole transcript. An explicit
946
+ ``layout=False`` still skips the layout unconditionally.
947
+ """
948
+ if layout:
949
+ update_static_in_place(self, content)
950
+ else:
951
+ super().update(content, layout=False)
952
+
953
+ def _resolve_action_labels(self) -> None:
954
+ localizer = widget_localizer(self)
955
+ self._view_label = render_str(localizer, TOOL_CARD_ACTION_VIEW.bind())
956
+ self._copy_label = render_str(localizer, TOOL_CARD_ACTION_COPY.bind())
957
+ if self._view_only_message is not None:
958
+ self._view_only_label = render_str(localizer, self._view_only_message)
959
+
960
+ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
961
+ """Header chrome is visual context, not transcript text."""
962
+ return None
963
+
964
+ @property
965
+ def actions_visible(self) -> bool:
966
+ """Whether the view/copy affordances are currently shown."""
967
+ return self._action_mode != "hidden"
968
+
969
+ @property
970
+ def copy_action_visible(self) -> bool:
971
+ """Whether the terminal copy affordance is currently shown."""
972
+ return self._action_mode == "full"
973
+
974
+ def show_actions(self) -> None:
975
+ """Reveal the view/copy affordances after a terminal tool state."""
976
+ if self._action_mode == "full":
977
+ return
978
+ self._action_mode = "full"
979
+ self._hover_zone = None
980
+ if self.is_mounted:
981
+ self._resolve_action_labels()
982
+ # Showing the actions narrows the label column, which can rewrap a
983
+ # long label — a layout refresh, but only once per card completion.
984
+ self.clear_cached_dimensions()
985
+ self.refresh(layout=True)
986
+
987
+ def append_replay_timing(
988
+ self,
989
+ *,
990
+ timestamp: str,
991
+ duration_ms: int,
992
+ show_duration: bool,
993
+ ) -> None:
994
+ """Append replay-only timing omitted by the renderer's normal label."""
995
+ if not timestamp and not show_duration:
996
+ return
997
+ label = self._label_renderable().copy()
998
+ if show_duration:
999
+ label.append(f" ({fmt_duration(duration_ms)})", style="dim")
1000
+ if timestamp:
1001
+ label.append(f" {timestamp}", style="dim")
1002
+ self.update(label)
1003
+
1004
+ def _label_renderable(self) -> Text:
1005
+ content = self.content
1006
+ if isinstance(content, Text):
1007
+ return content
1008
+ return Text(str(content))
1009
+
1010
+ def _zone_style(self, name: str) -> Style:
1011
+ # Component classes resolve theme variables like ``$text-muted``
1012
+ # ("auto 60%") through the stylesheet. Never feed
1013
+ # ``app.get_css_variables()`` values into Rich style strings: Rich
1014
+ # cannot parse tokens like "auto 60%" and silently drops the whole
1015
+ # style, leaving the span to inherit ancestor colors. The full
1016
+ # (non-partial) style is needed for the alpha blend against the
1017
+ # background (partial resolution yields unblended #ffffff), but its
1018
+ # stamped background color must be stripped so spans stay transparent
1019
+ # on ANSI passthrough themes.
1020
+ full = self.get_component_rich_style(name)
1021
+ return full.without_color + Style.from_color(full.color)
1022
+
1023
+ def _actions_text(self) -> Text:
1024
+ base = self._zone_style("toolcardheader--action")
1025
+ hover = self._zone_style("toolcardheader--action-hover")
1026
+ actions = Text(no_wrap=True)
1027
+ actions.append(" ")
1028
+ view_label = self._view_only_label if self._action_mode == "view_only" else self._view_label
1029
+ actions.append(view_label, style=hover if self._hover_zone == "view" else base)
1030
+ if self._action_mode == "full":
1031
+ actions.append(" / ", style=base)
1032
+ actions.append(self._copy_label, style=hover if self._hover_zone == "copy" else base)
1033
+ actions.append(" ")
1034
+ return actions
1035
+
1036
+ def _actions_width(self) -> int:
1037
+ """Return the rendered action-cell width for the current mode."""
1038
+ if self._action_mode == "view_only":
1039
+ return Text(self._view_only_label).cell_len + 2
1040
+ return self._ACTIONS_WIDTH
1041
+
1042
+ def render(self) -> RenderResult:
1043
+ if not self.actions_visible:
1044
+ return self.visual
1045
+ from rich.table import Table
1046
+
1047
+ row = Table.grid(expand=True)
1048
+ row.add_column(ratio=1)
1049
+ row.add_column(width=self._actions_width())
1050
+ row.add_row(self._label_renderable(), self._actions_text())
1051
+ return row
1052
+
1053
+ def _zone_at(self, offset: Offset) -> str | None:
1054
+ """Map a content-area offset to ``"view"``/``"copy"``, or ``None``."""
1055
+ if not self.actions_visible or offset.y != 0:
1056
+ return None
1057
+ actions_width = self._actions_width()
1058
+ actions_x = self.content_size.width - actions_width
1059
+ if actions_x < 0:
1060
+ # Header narrower than the actions cell: the rendered grid is
1061
+ # squeezed, so zone geometry no longer matches what's on screen.
1062
+ return None
1063
+ x = offset.x - actions_x
1064
+ if self._action_mode == "view_only":
1065
+ return "view" if 1 <= x < actions_width - 1 else None
1066
+ for zone, start, end in self._ACTION_ZONES:
1067
+ if start <= x < end:
1068
+ return zone
1069
+ return None
1070
+
1071
+ def _set_hover_zone(self, zone: str | None) -> None:
1072
+ if zone == self._hover_zone:
1073
+ return
1074
+ self._hover_zone = zone
1075
+ reference = self._ZONE_TOOLTIPS.get(zone) if zone else None
1076
+ self.tooltip = render_str(widget_localizer(self), reference) if reference is not None else None
1077
+ self.styles.pointer = "pointer" if zone else "default"
1078
+ self.refresh()
1079
+
1080
+ def on_mouse_move(self, event: MouseMove) -> None:
1081
+ self._set_hover_zone(self._zone_at(event.get_content_offset_capture(self)))
1082
+
1083
+ def on_leave(self, event: Leave) -> None:
1084
+ self._set_hover_zone(None)
1085
+
1086
+ def on_click(self, event: Click) -> None:
1087
+ zone = self._zone_at(event.get_content_offset_capture(self))
1088
+ if zone is None:
1089
+ return
1090
+ event.prevent_default()
1091
+ event.stop()
1092
+ if zone == "view":
1093
+ self.post_message(ToolViewButton.Clicked())
1094
+ else:
1095
+ self.post_message(ToolCopyButton.Clicked())
1096
+
1097
+
1098
+ def is_tool_copy_excluded(widget: Widget) -> bool:
1099
+ """Return true when *widget* is inside tool UI excluded from copying.
1100
+
1101
+ Walked nearest-ancestor-first so the closest marker wins: a subtree
1102
+ carrying ``TOOL_COPY_INCLUDED_CLASS`` can opt back into selection/copy
1103
+ inside an otherwise excluded card.
1104
+ """
1105
+ for ancestor in widget.ancestors_with_self:
1106
+ if ancestor.has_class(TOOL_COPY_INCLUDED_CLASS):
1107
+ return False
1108
+ if ancestor.has_class(TOOL_COPY_EXCLUDED_CLASS):
1109
+ return True
1110
+ return False
1111
+
1112
+
1113
+ def _fenced_block(language: str, text: str) -> str:
1114
+ """Return a Markdown tilde fence long enough for *text*."""
1115
+ text = _sanitize_copy_text(text)
1116
+ max_run = max((len(match.group(0)) for match in re.finditer(r"~+", text)), default=0)
1117
+ fence = "~" * max(3, max_run + 1)
1118
+ lang = language.strip()
1119
+ opener = f"{fence}{lang}" if lang else fence
1120
+ return f"{opener}\n{text}\n{fence}"
1121
+
1122
+
1123
+ def _sanitize_copy_text(text: str) -> str:
1124
+ """Escape terminal control characters in copied tool payloads."""
1125
+ sanitized: list[str] = []
1126
+ changed = False
1127
+ for char in text:
1128
+ codepoint = ord(char)
1129
+ if (codepoint < 0x20 and char not in {"\n", "\t"}) or 0x7F <= codepoint <= 0x9F:
1130
+ sanitized.append(f"\\x{codepoint:02x}")
1131
+ changed = True
1132
+ else:
1133
+ sanitized.append(char)
1134
+ if not changed:
1135
+ return text
1136
+ return "".join(sanitized)
1137
+
1138
+
1139
+ # ---------------------------------------------------------------------------
1140
+ # BaseToolCard / ToolCall — single tool invocation
1141
+ # ---------------------------------------------------------------------------
1142
+
1143
+
1144
+ class BaseToolCard(ToolCopyExcludedMixin, Widget):
1145
+ """Shared state and copy/view behavior for tool renderer cards."""
1146
+
1147
+ status: reactive[str] = reactive("running")
1148
+
1149
+ def __init__(
1150
+ self,
1151
+ call_id: str,
1152
+ tool_name: str,
1153
+ args_summary: str = "",
1154
+ args: dict[str, Any] | None = None,
1155
+ ) -> None:
1156
+ super().__init__()
1157
+ self.call_id = call_id
1158
+ self.tool_name = tool_name
1159
+ self.args_summary = args_summary
1160
+ self.args = args or {}
1161
+ self.tool_kind = ""
1162
+ self.result_text = ""
1163
+ self.metadata: dict[str, Any] = {}
1164
+ self.image_contents: list[Any] = []
1165
+ self.duration_ms = 0
1166
+ self.approval: str | None = None
1167
+ self.provider_hosted = False
1168
+ self.hosted_family = ""
1169
+ self.provider = ""
1170
+ self.provider_item_type = ""
1171
+ self.provider_status = ""
1172
+ self.provider_call_id = ""
1173
+ self.canonical_status = "running"
1174
+ self.artifacts: list[dict[str, Any]] = []
1175
+
1176
+ def configure_hosted(
1177
+ self,
1178
+ *,
1179
+ provider_hosted: bool,
1180
+ hosted_family: str,
1181
+ provider: str,
1182
+ provider_item_type: str,
1183
+ provider_status: str,
1184
+ provider_call_id: str,
1185
+ canonical_status: str,
1186
+ artifacts: list[dict[str, Any]] | None = None,
1187
+ ) -> None:
1188
+ """Install provider-hosted presentation metadata before mount."""
1189
+ self.provider_hosted = provider_hosted
1190
+ self.hosted_family = hosted_family
1191
+ self.provider = provider
1192
+ self.provider_item_type = provider_item_type
1193
+ self.provider_status = provider_status
1194
+ self.provider_call_id = provider_call_id
1195
+ self.canonical_status = canonical_status
1196
+ self.artifacts = list(artifacts or [])
1197
+
1198
+
1199
+ class ToolCall(BaseToolCard):
1200
+ """Single tool call with animated spinner, name, args, result, and duration."""
1201
+
1202
+ DEFAULT_CSS = """
1203
+ ToolCall {
1204
+ padding: 0 0 0 2;
1205
+ height: auto;
1206
+ margin: 0 0 1 0;
1207
+ }
1208
+ ToolCall #tc-label {
1209
+ height: auto;
1210
+ }
1211
+ ToolCall #tc-body {
1212
+ height: auto;
1213
+ color: $text-muted;
1214
+ }
1215
+ ToolCall > #tc-panel {
1216
+ height: auto;
1217
+ margin: 0 0 0 2;
1218
+ border: round $tui-border-primary 50%;
1219
+ border-title-color: $text-error;
1220
+ border-title-style: bold;
1221
+ border-subtitle-align: right;
1222
+ border-subtitle-color: $text-muted;
1223
+ padding: 0 1;
1224
+ }
1225
+ ToolCall.-success > #tc-panel {
1226
+ border: round $tui-border-success 30%;
1227
+ border-title-color: $success;
1228
+ border-subtitle-color: $success;
1229
+ border-title-style: not bold;
1230
+ }
1231
+ ToolCall.-error > #tc-panel {
1232
+ border: round $tui-border-error 50%;
1233
+ border-title-color: $text-error;
1234
+ border-subtitle-color: $text-error;
1235
+ border-title-style: not bold;
1236
+ }
1237
+ ToolCall.-rejected > #tc-panel {
1238
+ border: round $tui-border-warning 50%;
1239
+ border-title-color: $warning;
1240
+ border-subtitle-color: $warning;
1241
+ border-title-style: not bold;
1242
+ }
1243
+ """
1244
+
1245
+ _SPINNERS: ClassVar[str] = "◐◓◑◒"
1246
+
1247
+ def __init__(
1248
+ self,
1249
+ call_id: str,
1250
+ tool_name: str,
1251
+ args_summary: str = "",
1252
+ args: dict[str, Any] | None = None,
1253
+ ) -> None:
1254
+ super().__init__(call_id, tool_name, args_summary, args=args)
1255
+ self._spin_idx = 0
1256
+ self._timer: Timer | None = None
1257
+ # Progress snapshots may claim the body while the card is still
1258
+ # running; the spinner timer must not repaint over them.
1259
+ self._body_pinned = False
1260
+
1261
+ def _label_text(self, duration_ms: int = 0) -> Text:
1262
+ t = Text()
1263
+ t.append("• ", style="bold")
1264
+ t.append(self.tool_name, style="bold")
1265
+ if duration_ms:
1266
+ t.append(f" ({fmt_duration(duration_ms)})", style="dim")
1267
+ return t
1268
+
1269
+ @staticmethod
1270
+ def _format_args_title(args_summary: str) -> str:
1271
+ """Format JSON args as ``key: value key2: value2`` for border title."""
1272
+ try:
1273
+ parsed = json.loads(args_summary)
1274
+ except Exception:
1275
+ return sanitize_legacy_scalar(args_summary)
1276
+ if not isinstance(parsed, dict):
1277
+ return sanitize_legacy_scalar(args_summary)
1278
+ parts: list[str] = []
1279
+ for k, v in parsed.items():
1280
+ if isinstance(v, str):
1281
+ parts.append(f'{k}: "{sanitize_legacy_scalar(v)}"')
1282
+ else:
1283
+ parts.append(f"{k}: {sanitize_legacy_scalar(json.dumps(v, ensure_ascii=False))}")
1284
+ return " ".join(parts)
1285
+
1286
+ def _border_title_text(self) -> str:
1287
+ title = self.tool_name
1288
+ if self.args_summary:
1289
+ title = self._format_args_title(self.args_summary) or self.tool_name
1290
+ if len(title) > _ARGS_DISPLAY_MAX:
1291
+ title = title[:_ARGS_DISPLAY_MAX] + "..."
1292
+ return title
1293
+
1294
+ def compose(self) -> ComposeResult:
1295
+ yield ToolCardHeader(self._label_text(), id="tc-label")
1296
+ panel = Widget(id="tc-panel")
1297
+ panel.border_title = Text(self._border_title_text())
1298
+ yield panel
1299
+
1300
+ def update_args(self, args: dict[str, Any]) -> None:
1301
+ """Refresh the displayed arguments after an approval edit."""
1302
+ self.args = args
1303
+ self.args_summary = json.dumps(args, ensure_ascii=False) if args else ""
1304
+ with suppress(Exception):
1305
+ self.query_one("#tc-panel").border_title = Text(self._border_title_text())
1306
+
1307
+ def on_mount(self) -> None:
1308
+ panel = self.query_one("#tc-panel")
1309
+ # One body Static hosts the spinner while running and the result text
1310
+ # after completion — content swaps instead of paired display toggles.
1311
+ panel.mount(Static(self._render_spinner(), id="tc-body"))
1312
+ self._timer = self.set_interval(0.12, self._spin)
1313
+
1314
+ def on_unmount(self) -> None:
1315
+ if self._timer is not None:
1316
+ self._timer.stop()
1317
+
1318
+ def _spin(self) -> None:
1319
+ if self.status == "running" and not self._body_pinned:
1320
+ self._spin_idx = (self._spin_idx + 1) % len(self._SPINNERS)
1321
+ # Only a shown card repaints: repainting a widget outside the visible cut (a collapsed
1322
+ # group, scrolled away, under a modal) makes the next geometry read rebuild the
1323
+ # compositor map for the whole transcript. Every tick renders the card's current
1324
+ # state, so the first tick after the card shows paints it.
1325
+ if not is_widget_shown_on_active_screen(self):
1326
+ return
1327
+ from contextlib import suppress
1328
+
1329
+ with suppress(Exception):
1330
+ update_static_in_place(self.query_one("#tc-body", Static), self._render_spinner())
1331
+
1332
+ def _render_spinner(self) -> Text:
1333
+ t = Text()
1334
+ t.append(f"{self._SPINNERS[self._spin_idx]} ", style="yellow")
1335
+ t.append(render_str(widget_localizer(self), TOOL_CARD_RUNNING.bind()), style="yellow")
1336
+ return t
1337
+
1338
+ def _truncate_result(self, text: str) -> str:
1339
+ """Truncate result text for display. Subclasses may override."""
1340
+ if len(text) > _RESULT_DISPLAY_MAX:
1341
+ return text[:_RESULT_DISPLAY_MAX] + "..."
1342
+ return text
1343
+
1344
+ def _render_image_contents(self, image_contents: list[Any] | None) -> None:
1345
+ from chrys.app.tui.widgets.chat.image_preview import ImagePreviewGrid, extract_image_previews
1346
+
1347
+ previews = extract_image_previews(image_contents)
1348
+ existing: Static | None = None
1349
+ with suppress(Exception):
1350
+ existing = self.query_one("#tc-images", Static)
1351
+ if not previews:
1352
+ if existing is not None:
1353
+ existing.remove()
1354
+ return
1355
+ max_width = self.content_size.width or self.size.width or 80
1356
+ grid = ImagePreviewGrid(previews, max_width=max_width)
1357
+ try:
1358
+ panel = self.query_one("#tc-panel")
1359
+ except NoMatches:
1360
+ return
1361
+ subtitle = _image_border_subtitle(
1362
+ image_contents,
1363
+ previews[0].image.size if len(previews) == 1 else None,
1364
+ render_message=lambda reference: render_str(widget_localizer(self), reference),
1365
+ )
1366
+ if subtitle:
1367
+ panel.border_subtitle = Text(subtitle)
1368
+ if existing is not None:
1369
+ existing.update(grid)
1370
+ else:
1371
+ with suppress(Exception):
1372
+ panel.mount(Static(grid, id="tc-images"))
1373
+
1374
+ def set_complete(self, result: str, duration_ms: int = 0, **kwargs: Any) -> None:
1375
+ """Mark this tool call as complete."""
1376
+ from contextlib import suppress
1377
+
1378
+ self.result_text = result
1379
+ self.duration_ms = duration_ms
1380
+ self.approval = kwargs.get("approval")
1381
+ metadata = kwargs.get("metadata")
1382
+ self.metadata = metadata if isinstance(metadata, dict) else {}
1383
+ render_status = tool_result_render_status(result, self.approval, self.tool_kind, self.metadata, self.tool_name)
1384
+ self.status = render_status
1385
+ if self._timer is not None:
1386
+ self._timer.stop()
1387
+
1388
+ with suppress(Exception):
1389
+ self.query_one("#tc-label", Static).update(self._label_text(duration_ms))
1390
+
1391
+ panel: Widget | None = None
1392
+ with suppress(NoMatches):
1393
+ panel = self.query_one("#tc-panel")
1394
+ approval = self.approval
1395
+ if approval == "user_approved" and panel is not None:
1396
+ panel.border_subtitle = render_text(widget_localizer(self), TOOL_CARD_APPROVED.bind())
1397
+ elif render_status == "rejected" and panel is not None:
1398
+ panel.border_subtitle = render_text(widget_localizer(self), TOOL_CARD_REJECTED.bind())
1399
+
1400
+ if render_status == "rejected":
1401
+ self.add_class("-rejected")
1402
+ elif render_status == "error":
1403
+ if panel is not None:
1404
+ panel.border_subtitle = render_text(widget_localizer(self), TOOL_CARD_ERRORED.bind())
1405
+ self.add_class("-error")
1406
+ else:
1407
+ self.add_class("-success")
1408
+ image_contents = kwargs.get("image_contents")
1409
+ self.image_contents = list(image_contents or [])
1410
+ display = _image_result_display(result, image_contents)
1411
+ display = self._truncate_result(display) if display else ""
1412
+ with suppress(Exception):
1413
+ self.query_one("#tc-body", Static).update(Text(sanitize_legacy_block(display)))
1414
+ self._render_image_contents(image_contents)
1415
+ self.add_class("-done")
1416
+ self._show_tool_copy_button()
1417
+
1418
+ def set_error(self, error: str) -> None:
1419
+ """Mark this tool call as failed."""
1420
+ from contextlib import suppress
1421
+
1422
+ self.result_text = error
1423
+ self.status = "error"
1424
+ if self._timer is not None:
1425
+ self._timer.stop()
1426
+
1427
+ self.add_class("-error")
1428
+ with suppress(Exception):
1429
+ self.query_one("#tc-body", Static).update(Text(sanitize_legacy_block(error)))
1430
+ self.add_class("-done")
1431
+ self._show_tool_copy_button()
1432
+
1433
+
1434
+ # ---------------------------------------------------------------------------
1435
+ # ToolGroupTitle — clickable header that toggles collapse
1436
+ # ---------------------------------------------------------------------------
1437
+
1438
+
1439
+ class ToolGroupTitle(ToolCopyExcludedMixin, ClickAffordance):
1440
+ """Clickable title for ToolGroup — posts Clicked message on click."""
1441
+
1442
+ DEFAULT_CSS = """
1443
+ ToolGroupTitle {
1444
+ color: $tui-tool-group-title;
1445
+ text-style: bold;
1446
+ height: auto;
1447
+ }
1448
+ """
1449
+
1450
+ class Clicked(Message):
1451
+ """Posted when the title is clicked."""
1452
+
1453
+ CLICK_MESSAGE = Clicked
1454
+
1455
+ def __init__(self, title: str | Text) -> None:
1456
+ self._full_title = title.copy() if isinstance(title, Text) else Text(title)
1457
+ super().__init__(self._full_title)
1458
+
1459
+ def set_title(self, title: Text) -> None:
1460
+ """Store the unabridged title and refresh its fitted rendering."""
1461
+ self._full_title = title.copy()
1462
+ self.refresh(layout=False)
1463
+
1464
+ def render(self) -> Text:
1465
+ """Render a single-line title with a visible narrow-width ellipsis."""
1466
+ title = self._full_title.copy()
1467
+ available = self.content_size.width or self.size.width
1468
+ if available > 0:
1469
+ title.truncate(available, overflow="ellipsis")
1470
+ return title
1471
+
1472
+
1473
+ # ---------------------------------------------------------------------------
1474
+ # ToolGroup — collapsible container of ToolCall widgets
1475
+ # ---------------------------------------------------------------------------
1476
+
1477
+
1478
+ class ToolGroup(ToolCopyExcludedMixin, Widget):
1479
+ """Collapsible group of tool calls within a single agent turn.
1480
+
1481
+ Consecutive tool starts are grouped together. The title shows progress
1482
+ (done/total) and cumulative duration.
1483
+ """
1484
+
1485
+ DEFAULT_CSS = """
1486
+ ToolGroup {
1487
+ margin: 1 0;
1488
+ padding: 0 1;
1489
+ border-left: thick $tui-border-warning $border-opacity;
1490
+ height: auto;
1491
+ }
1492
+ ToolGroup > #tg-header {
1493
+ height: 1;
1494
+ width: 100%;
1495
+ }
1496
+ ToolGroup > #tg-header > ToolGroupTitle {
1497
+ height: 1;
1498
+ width: 1fr;
1499
+ overflow: hidden;
1500
+ }
1501
+ ToolGroup > #tg-header > ChrysLoadingIndicator {
1502
+ display: none;
1503
+ color: $tui-tool-group-title;
1504
+ height: 1;
1505
+ width: 6;
1506
+ min-width: 6;
1507
+ max-width: 6;
1508
+ }
1509
+ ToolGroup > Vertical {
1510
+ height: auto;
1511
+ }
1512
+ """
1513
+
1514
+ collapsed: reactive[bool] = reactive(False)
1515
+
1516
+ def __init__(
1517
+ self,
1518
+ *,
1519
+ live: bool = True,
1520
+ tool_widget_configurator: Callable[[Widget], None] | None = None,
1521
+ ) -> None:
1522
+ self._collapse_lock_call_ids: set[str] = set()
1523
+ super().__init__()
1524
+ self._live = live
1525
+ self._tool_widget_configurator = tool_widget_configurator
1526
+ self._tools: dict[str, Widget] = {}
1527
+ self._tool_records: dict[str, _ToolRecord] = {}
1528
+ self._activity_sequence = 0
1529
+ self._activity_header_finalized = not live
1530
+ self._retained_activity_text = ""
1531
+ self._last_title_plain = ""
1532
+ self._done = 0
1533
+ self._start_time = time.monotonic()
1534
+ self._end_time: float | None = None
1535
+ self._timer: Timer | None = None
1536
+ self._pending_content_worker: Any | None = None
1537
+ self._content_structure_lock = asyncio.Lock()
1538
+ self._content_mounted = True
1539
+ self._content_freeze_epoch: int | None = None
1540
+ """GC freeze epoch read before the oldest mounted tool widget was built; None while none is."""
1541
+ self.display = False # hidden until first tool is added
1542
+
1543
+ def compose(self) -> ComposeResult:
1544
+ with Horizontal(id="tg-header"):
1545
+ if self._live:
1546
+ yield ChrysLoadingIndicator(id="tg-activity-indicator")
1547
+ yield ToolGroupTitle(self._title_text())
1548
+ content = Vertical(id="tg-content")
1549
+ content.display = not self.collapsed
1550
+ yield content
1551
+
1552
+ def on_mount(self) -> None:
1553
+ # Bulk replay can populate a completed logical group before Textual
1554
+ # attaches it. Do not start an elapsed-time timer that completion has
1555
+ # already conceptually stopped.
1556
+ if not self.all_complete:
1557
+ self._timer = self.set_interval(1.0, self._tick)
1558
+ self._sync_activity_indicator()
1559
+
1560
+ def on_unmount(self) -> None:
1561
+ if self._timer is not None:
1562
+ self._timer.stop()
1563
+ self._timer = None
1564
+ self._cancel_pending_content_mounts()
1565
+
1566
+ def _accepts_tool_mounts(self) -> bool:
1567
+ """Whether renderer widgets may still be attached below this group."""
1568
+ return self.is_attached and not self._closing and not self._pruning
1569
+
1570
+ def _tick(self) -> None:
1571
+ """Update title with live elapsed time while tools are running."""
1572
+ if self._end_time is None and self._tools:
1573
+ from contextlib import suppress
1574
+
1575
+ with suppress(Exception):
1576
+ self._update_title()
1577
+
1578
+ def get_tool(self, call_id: str) -> Widget | None:
1579
+ """Return the tool widget for ``call_id``, or ``None`` if not in this group.
1580
+
1581
+ Public accessor used by :class:`ChatPanel` to resolve the widget
1582
+ mounted for a given tool call without reaching into the internal
1583
+ ``_tools`` map.
1584
+ """
1585
+ return self._tools.get(call_id)
1586
+
1587
+ def reveal_tool(self, call_id: str) -> Widget | None:
1588
+ """Expand the group and return the mounted widget for ``call_id`` if available."""
1589
+ if self.collapsed:
1590
+ self.collapsed = False
1591
+ if not self._content_mounted:
1592
+ return None
1593
+ return self._tools.get(call_id)
1594
+
1595
+ def lock_collapse_for(self, call_id: str) -> None:
1596
+ """Keep this group expanded while a live inline control is active."""
1597
+ if not call_id:
1598
+ return
1599
+ self._collapse_lock_call_ids.add(call_id)
1600
+ if self.collapsed:
1601
+ self.collapsed = False
1602
+
1603
+ def unlock_collapse_for(self, call_id: str) -> None:
1604
+ """Release a live inline control's collapse lock."""
1605
+ self._collapse_lock_call_ids.discard(call_id)
1606
+
1607
+ @property
1608
+ def collapse_locked(self) -> bool:
1609
+ """True while live inline controls require this group to stay expanded."""
1610
+ return bool(self._collapse_lock_call_ids)
1611
+
1612
+ def validate_collapsed(self, collapsed: bool) -> bool:
1613
+ """Prevent any caller from hiding live inline controls."""
1614
+ if collapsed and self.collapse_locked:
1615
+ return False
1616
+ return collapsed
1617
+
1618
+ def is_tool_running(self, call_id: str) -> bool:
1619
+ """Return whether the logical tool record for ``call_id`` is still running."""
1620
+ record = self._tool_records.get(call_id)
1621
+ return record is not None and record.status == "running"
1622
+
1623
+ def _next_activity_order(self) -> int:
1624
+ self._activity_sequence += 1
1625
+ return self._activity_sequence
1626
+
1627
+ def finalize_activity_header(self) -> None:
1628
+ """Switch to the count title once this group reaches a non-tool boundary.
1629
+
1630
+ A still-running record continues to win until it actually finishes;
1631
+ this matters for sub-agents whose group can close while their work is
1632
+ still routed to the detached card.
1633
+ """
1634
+ if self._activity_header_finalized:
1635
+ return
1636
+ self._activity_header_finalized = True
1637
+ self._update_title()
1638
+
1639
+ def clear_ask_user_inline_prompts(self) -> None:
1640
+ """Remove inline ask_user controls mounted in this group."""
1641
+ from chrys.app.tui.widgets.chat.renderers.ask_user import AskUserToolCall
1642
+
1643
+ for tc in self._tools.values():
1644
+ if isinstance(tc, AskUserToolCall):
1645
+ tc.clear_inline_prompt()
1646
+ self._collapse_lock_call_ids.clear()
1647
+
1648
+ async def add_tool(
1649
+ self,
1650
+ call_id: str,
1651
+ tool_name: str,
1652
+ tool_kind: str,
1653
+ args_summary: str = "",
1654
+ args: dict[str, Any] | None = None,
1655
+ *,
1656
+ provider_hosted: bool = False,
1657
+ hosted_family: str = "",
1658
+ provider: str = "",
1659
+ provider_item_type: str = "",
1660
+ provider_status: str = "",
1661
+ provider_call_id: str = "",
1662
+ canonical_status: str = "running",
1663
+ ) -> None:
1664
+ """Add a running tool call to this group."""
1665
+ from chrys.app.tui.widgets.chat.tool_renderers import create_tool_widget
1666
+
1667
+ if not self._accepts_tool_mounts():
1668
+ return
1669
+ self._tool_records[call_id] = _ToolRecord(
1670
+ call_id=call_id,
1671
+ tool_name=tool_name,
1672
+ tool_kind=tool_kind,
1673
+ args_summary=args_summary,
1674
+ args=args,
1675
+ provider_hosted=provider_hosted,
1676
+ hosted_family=hosted_family,
1677
+ provider=provider,
1678
+ provider_item_type=provider_item_type,
1679
+ provider_status=provider_status,
1680
+ provider_call_id=provider_call_id,
1681
+ canonical_status=canonical_status,
1682
+ activity_order=self._next_activity_order(),
1683
+ )
1684
+ if not self.display:
1685
+ self.display = True
1686
+ self._end_time = None
1687
+ self._ensure_timer()
1688
+ async with self._content_structure_lock:
1689
+ # The structure lock may have yielded behind a collapse restore or
1690
+ # prune. Revalidate before constructing a renderer: Textual keeps
1691
+ # composed descendants queryable briefly even when their ancestor
1692
+ # can no longer accept mounts.
1693
+ if not self._accepts_tool_mounts():
1694
+ self._tool_records.pop(call_id, None)
1695
+ self._finish_if_all_complete()
1696
+ return
1697
+ if existing := self._tools.get(call_id):
1698
+ if record := self._tool_records.get(call_id):
1699
+ self._apply_tool_record_state(record, existing)
1700
+ self._update_title()
1701
+ return
1702
+ if not self._content_mounted:
1703
+ await self._restore_completed_tool_widgets_locked(allow_collapsed=True)
1704
+ self._update_title()
1705
+ return
1706
+
1707
+ self._note_content_build_epoch()
1708
+ if provider_hosted:
1709
+ tc = create_tool_widget(
1710
+ call_id,
1711
+ tool_name,
1712
+ tool_kind,
1713
+ args_summary,
1714
+ args=args,
1715
+ provider_hosted=True,
1716
+ hosted_family=hosted_family,
1717
+ provider=provider,
1718
+ provider_item_type=provider_item_type,
1719
+ provider_status=provider_status,
1720
+ provider_call_id=provider_call_id,
1721
+ canonical_status=canonical_status,
1722
+ )
1723
+ else:
1724
+ tc = create_tool_widget(call_id, tool_name, tool_kind, args_summary, args=args)
1725
+ self._configure_tool_widget(tc)
1726
+ self._tools[call_id] = tc
1727
+ # EventBus delivery normally leaves a scheduler turn between start
1728
+ # and result. Nested transcript journals can replay both back to
1729
+ # back, so compose the renderer tree before mounting its root; a
1730
+ # terminal update may then safely query renderer descendants as
1731
+ # soon as ``add_tool`` returns.
1732
+ precompose_tree([tc])
1733
+ await self.query_one("#tg-content").mount(tc)
1734
+ if record := self._tool_records.get(call_id):
1735
+ self._apply_tool_record_state(record, tc)
1736
+ self._content_mounted = True
1737
+ self._update_title()
1738
+
1739
+ async def add_collapsed_replay_tool(
1740
+ self,
1741
+ call_id: str,
1742
+ tool_name: str,
1743
+ tool_kind: str,
1744
+ args_summary: str = "",
1745
+ args: dict[str, Any] | None = None,
1746
+ *,
1747
+ result: str = "",
1748
+ duration_ms: int = 0,
1749
+ duration_known: bool = False,
1750
+ timestamp: str = "",
1751
+ file_snapshot: FileSnapshotPayload | None = None,
1752
+ approval: str | None = None,
1753
+ metadata: dict[str, Any] | None = None,
1754
+ image_contents: list[Any] | None = None,
1755
+ artifacts: list[dict[str, Any]] | None = None,
1756
+ provider_hosted: bool = False,
1757
+ hosted_family: str = "",
1758
+ provider: str = "",
1759
+ provider_item_type: str = "",
1760
+ provider_status: str = "",
1761
+ provider_call_id: str = "",
1762
+ canonical_status: str = "completed",
1763
+ lazy: bool = False,
1764
+ ) -> None:
1765
+ """Add a completed replay tool without constructing its renderer widget."""
1766
+ async with self._content_structure_lock:
1767
+ render_status = (
1768
+ "error"
1769
+ if canonical_status in {"failed", "interrupted"}
1770
+ else tool_result_render_status(result, approval, tool_kind, metadata, tool_name)
1771
+ )
1772
+ self._tool_records[call_id] = _ToolRecord(
1773
+ call_id=call_id,
1774
+ tool_name=tool_name,
1775
+ tool_kind=tool_kind,
1776
+ args_summary=args_summary,
1777
+ args=args,
1778
+ status=render_status,
1779
+ result=result,
1780
+ duration_ms=duration_ms,
1781
+ duration_known=duration_known or duration_ms != 0,
1782
+ timestamp=timestamp,
1783
+ replay_timing=True,
1784
+ file_snapshot=file_snapshot,
1785
+ approval=approval,
1786
+ metadata=_compact_tool_metadata(metadata),
1787
+ image_contents=list(image_contents or []),
1788
+ lazy=lazy,
1789
+ completed_via_result=not provider_hosted or canonical_status == "completed",
1790
+ provider_hosted=provider_hosted,
1791
+ hosted_family=hosted_family,
1792
+ provider=provider,
1793
+ provider_item_type=provider_item_type,
1794
+ provider_status=provider_status,
1795
+ provider_call_id=provider_call_id,
1796
+ canonical_status=canonical_status,
1797
+ artifacts=list(artifacts or []),
1798
+ activity_order=self._next_activity_order(),
1799
+ )
1800
+ self._tools.pop(call_id, None)
1801
+ self.display = True
1802
+ self._content_mounted = False
1803
+ self._reconcile_done_count()
1804
+ if self.all_complete:
1805
+ self._end_time = self._start_time
1806
+ self._stop_timer()
1807
+ if not self.collapsed:
1808
+ self.collapsed = True
1809
+ else:
1810
+ self._update_title()
1811
+
1812
+ def update_tool_args(self, call_id: str, args: dict[str, Any]) -> None:
1813
+ """Refresh arguments for a running tool card."""
1814
+ record = self._tool_records.get(call_id)
1815
+ if (
1816
+ record is not None
1817
+ and record.canonical_status in _TERMINAL_CANONICAL_STATUSES
1818
+ and (record.completed_via_result or record.canonical_status == "interrupted")
1819
+ ):
1820
+ # Same window as complete_tool: a status-only terminal transition
1821
+ # still awaits the real result item, which may carry the arguments.
1822
+ logger.debug("Ignoring late arguments for terminal tool call %s", call_id)
1823
+ return
1824
+ tc = self._tools.get(call_id)
1825
+ if isinstance(tc, _ToolArgsUpdatable):
1826
+ tc.update_args(args)
1827
+ if record is not None:
1828
+ record.args = args
1829
+ record.args_summary = json.dumps(args, ensure_ascii=False) if args else ""
1830
+ self._update_title()
1831
+
1832
+ def update_nested_tool_activity(
1833
+ self,
1834
+ parent_call_id: str,
1835
+ call_id: str,
1836
+ *,
1837
+ tool_name: str | None = None,
1838
+ tool_kind: str | None = None,
1839
+ args: dict[str, Any] | None = None,
1840
+ status: str | None = None,
1841
+ ) -> None:
1842
+ """Update presentation-only state for a tool running inside a sub-agent."""
1843
+ record = self._tool_records.get(parent_call_id)
1844
+ if record is None or record.status != "running":
1845
+ return
1846
+ if status in _TERMINAL_CANONICAL_STATUSES:
1847
+ if record.nested_activities.pop(call_id, None) is not None:
1848
+ self._update_title()
1849
+ return
1850
+ activity = record.nested_activities.get(call_id)
1851
+ if activity is None:
1852
+ if tool_name is None:
1853
+ return
1854
+ activity = _NestedToolActivity(
1855
+ tool_name=tool_name,
1856
+ tool_kind=tool_kind or "",
1857
+ args=_compact_activity_args(args),
1858
+ )
1859
+ record.nested_activities[call_id] = activity
1860
+ else:
1861
+ if tool_name is not None:
1862
+ activity.tool_name = tool_name
1863
+ if tool_kind is not None:
1864
+ activity.tool_kind = tool_kind
1865
+ if args is not None:
1866
+ activity.args = _compact_activity_args(args)
1867
+ order = self._next_activity_order()
1868
+ record.activity_order = order
1869
+ record.nested_latest_activity = _tool_activity_reference(
1870
+ activity.tool_name,
1871
+ activity.tool_kind,
1872
+ activity.args,
1873
+ )
1874
+ self._update_title()
1875
+
1876
+ def update_sub_agent_activity(self, parent_call_id: str, activity: _ToolActivity) -> None:
1877
+ """Replace a running sub-agent's latest assistant/tool activity."""
1878
+ record = self._tool_records.get(parent_call_id)
1879
+ if record is None or record.status != "running" or record.tool_kind != KIND_SUB_AGENT:
1880
+ return
1881
+ record.nested_latest_activity = activity
1882
+ record.activity_order = self._next_activity_order()
1883
+ self._update_title()
1884
+
1885
+ def update_sub_agent_display_name(self, parent_call_id: str, agent_name: str) -> None:
1886
+ """Apply the runtime display name without replacing the tool arguments."""
1887
+ if not agent_name:
1888
+ return
1889
+ record = self._tool_records.get(parent_call_id)
1890
+ if record is None or record.status != "running" or record.tool_kind != KIND_SUB_AGENT:
1891
+ return
1892
+ record.args = {**(record.args or {}), "agent_name": agent_name}
1893
+ record.activity_order = self._next_activity_order()
1894
+ self._update_title()
1895
+
1896
+ def _capture_tool_display_state(self, call_id: str, tc: Widget | None) -> None:
1897
+ """Store compact renderer display state on the logical tool record."""
1898
+ record = self._tool_records.get(call_id)
1899
+ if record is not None and isinstance(tc, _ToolDisplayStateSerializable):
1900
+ record.display_state = tc.compact_display_state()
1901
+
1902
+ def complete_tool(
1903
+ self,
1904
+ call_id: str,
1905
+ result: str,
1906
+ duration_ms: int = 0,
1907
+ *,
1908
+ image_contents: list[Any] | None = None,
1909
+ file_snapshot: FileSnapshotPayload | None = None,
1910
+ approval: str | None = None,
1911
+ metadata: dict[str, Any] | None = None,
1912
+ artifacts: list[dict[str, Any]] | None = None,
1913
+ provider_status: str = "",
1914
+ canonical_status: str = "completed",
1915
+ lazy: bool = False,
1916
+ ) -> None:
1917
+ """Mark a tool call as complete.
1918
+
1919
+ If *lazy* is True, the tool widget defers expensive diff
1920
+ construction until the group is first expanded (used during replay).
1921
+ """
1922
+ record = self._tool_records.get(call_id)
1923
+ if (
1924
+ record is not None
1925
+ and record.canonical_status in _TERMINAL_CANONICAL_STATUSES
1926
+ and (record.completed_via_result or record.canonical_status == "interrupted")
1927
+ ):
1928
+ logger.debug("Ignoring late result for terminal tool call %s", call_id)
1929
+ return
1930
+ was_terminal = record.status in _TERMINAL_TOOL_STATUSES if record is not None else False
1931
+ if record is not None:
1932
+ record.status = (
1933
+ "error"
1934
+ if canonical_status == "interrupted"
1935
+ else tool_result_render_status(
1936
+ result,
1937
+ approval,
1938
+ record.tool_kind,
1939
+ metadata,
1940
+ record.tool_name,
1941
+ )
1942
+ )
1943
+ record.result = result
1944
+ record.duration_ms = duration_ms
1945
+ record.duration_known = True
1946
+ record.file_snapshot = file_snapshot
1947
+ record.approval = approval
1948
+ record.metadata = _compact_tool_metadata(metadata)
1949
+ record.image_contents = list(image_contents or [])
1950
+ record.artifacts = list(artifacts or [])
1951
+ record.provider_status = provider_status or record.provider_status
1952
+ record.canonical_status = canonical_status
1953
+ record.lazy = lazy
1954
+ record.completed_via_result = True
1955
+ record.nested_activities.clear()
1956
+ tc = self._tools.get(call_id)
1957
+ if tc is not None:
1958
+ if isinstance(tc, BaseToolCard):
1959
+ tc.canonical_status = canonical_status
1960
+ tc.provider_status = provider_status or tc.provider_status
1961
+ tc.artifacts = list(artifacts or [])
1962
+ tc.metadata = dict(metadata or {})
1963
+ renderer = _completion_renderer(tc)
1964
+ if canonical_status == "interrupted":
1965
+ renderer.set_error(result or "interrupted")
1966
+ else:
1967
+ renderer.set_complete(
1968
+ result,
1969
+ duration_ms,
1970
+ image_contents=image_contents,
1971
+ file_snapshot=file_snapshot,
1972
+ approval=approval,
1973
+ metadata=metadata,
1974
+ artifacts=artifacts,
1975
+ lazy=lazy,
1976
+ )
1977
+ self._capture_tool_display_state(call_id, tc)
1978
+ if record is not None or tc is not None:
1979
+ if not was_terminal:
1980
+ self._done += 1
1981
+ self.unlock_collapse_for(call_id)
1982
+ self._finish_if_all_complete()
1983
+ self._update_title()
1984
+ if self.all_complete and self.collapsed:
1985
+ self.call_later(self._release_completed_tool_widgets)
1986
+
1987
+ def _ensure_timer(self) -> None:
1988
+ """Ensure elapsed-time title updates are active while the group is running."""
1989
+ if self._timer is None:
1990
+ self._timer = self.set_interval(1.0, self._tick)
1991
+
1992
+ def _stop_timer(self) -> None:
1993
+ """Stop elapsed-time title updates."""
1994
+ if self._timer is not None:
1995
+ self._timer.stop()
1996
+ self._timer = None
1997
+
1998
+ def _terminal_record_count(self) -> int:
1999
+ """Return how many logical tool records are already terminal."""
2000
+ return sum(1 for record in self._tool_records.values() if record.status in _TERMINAL_TOOL_STATUSES)
2001
+
2002
+ def _reconcile_done_count(self) -> None:
2003
+ """Keep done count consistent after hidden-record state transitions."""
2004
+ if self._tool_records:
2005
+ self._done = self._terminal_record_count()
2006
+
2007
+ def _finish_if_all_complete(self) -> None:
2008
+ """Stop the group timer once every logical tool record is terminal."""
2009
+ if self.all_complete:
2010
+ self._end_time = time.monotonic()
2011
+ self._stop_timer()
2012
+
2013
+ def _mark_record_error(self, call_id: str, error: str) -> None:
2014
+ """Mark one logical tool record as errored."""
2015
+ if record := self._tool_records.get(call_id):
2016
+ record.status = "error"
2017
+ record.result = error
2018
+ record.canonical_status = "interrupted" if error == "cancelled" else "failed"
2019
+ record.completed_via_result = False
2020
+ record.nested_activities.clear()
2021
+
2022
+ def cancel_running(self) -> None:
2023
+ """Mark all still-running tool calls as cancelled."""
2024
+ for call_id, tc in self._tools.items():
2025
+ renderer = _completion_renderer(tc)
2026
+ if renderer.status == "running":
2027
+ renderer.set_error("cancelled")
2028
+ self._mark_record_error(call_id, "cancelled")
2029
+ for call_id, record in self._tool_records.items():
2030
+ if record.status == "running":
2031
+ self._mark_record_error(call_id, "cancelled")
2032
+ self._collapse_lock_call_ids.clear()
2033
+ self._reconcile_done_count()
2034
+ self._finish_if_all_complete()
2035
+ self._update_title()
2036
+
2037
+ @property
2038
+ def all_complete(self) -> bool:
2039
+ """True when all tool calls in this group have finished."""
2040
+ total = len(self._tool_records) if self._tool_records else len(self._tools)
2041
+ return self._done == total and total > 0
2042
+
2043
+ def _elapsed_ms(self) -> int:
2044
+ end = self._end_time if self._end_time is not None else time.monotonic()
2045
+ return int((end - self._start_time) * 1000)
2046
+
2047
+ def _title_text(self) -> Text:
2048
+ arrow = "▶" if self.collapsed else "▼"
2049
+ running = [
2050
+ record for record in self._tool_records.values() if record is not None and record.status == "running"
2051
+ ]
2052
+ if running:
2053
+ record = max(running, key=lambda candidate: candidate.activity_order)
2054
+ localizer = widget_localizer(self)
2055
+ activity = _record_activity_reference(record, lambda reference: render_str(localizer, reference))
2056
+ title = activity if isinstance(activity, str) else render_str(localizer, activity)
2057
+ self._retained_activity_text = title
2058
+ return Text(title, no_wrap=True, overflow="ellipsis")
2059
+ if not self._activity_header_finalized and self._retained_activity_text:
2060
+ return Text(
2061
+ self._retained_activity_text,
2062
+ no_wrap=True,
2063
+ overflow="ellipsis",
2064
+ )
2065
+ n = len(self._tool_records) if self._tool_records else len(self._tools)
2066
+ elapsed = self._elapsed_ms()
2067
+ if elapsed >= 1000:
2068
+ reference = _TOOL_CARD_GROUP_TITLE_TIMED.bind(done=self._done, total=n, duration=fmt_duration(elapsed))
2069
+ else:
2070
+ reference = _TOOL_CARD_GROUP_TITLE.bind(done=self._done, total=n)
2071
+ title = render_str(widget_localizer(self), reference)
2072
+ return Text(f"{arrow} {title}", no_wrap=True, overflow="ellipsis")
2073
+
2074
+ def _update_title(self) -> None:
2075
+ title = self._title_text()
2076
+ self._sync_activity_indicator()
2077
+ if title.plain == self._last_title_plain:
2078
+ return
2079
+ try:
2080
+ title_widget = self.query_one(ToolGroupTitle)
2081
+ except NoMatches:
2082
+ return
2083
+ title_widget.set_title(title)
2084
+ self._last_title_plain = title.plain
2085
+
2086
+ def _sync_activity_indicator(self) -> None:
2087
+ """Animate through the open tool phase and any detached running work."""
2088
+ if not self._live:
2089
+ return
2090
+ with suppress(NoMatches):
2091
+ indicator = self.query_one("#tg-activity-indicator", ChrysLoadingIndicator)
2092
+ has_running_tool = any(
2093
+ record is not None and record.status == "running" for record in self._tool_records.values()
2094
+ )
2095
+ active = not self._activity_header_finalized or has_running_tool
2096
+ indicator.display = active
2097
+ if active:
2098
+ indicator.resume_animation()
2099
+ else:
2100
+ indicator.pause_animation()
2101
+
2102
+ def watch_collapsed(self, collapsed: bool) -> None:
2103
+ try:
2104
+ content = self.query_one("#tg-content")
2105
+ except NoMatches:
2106
+ return
2107
+ content.display = not collapsed
2108
+ self._update_title()
2109
+ if collapsed:
2110
+ self._cancel_pending_content_mounts()
2111
+ self._release_expensive_tool_content()
2112
+ self.call_later(self._release_completed_tool_widgets)
2113
+ else:
2114
+ if self._content_mounted:
2115
+ # On first expand, trigger lazy diff mounting for any tool
2116
+ # widgets that deferred their diff construction during replay.
2117
+ self._schedule_pending_content_mounts()
2118
+ else:
2119
+ self.call_later(self._restore_completed_tool_widgets)
2120
+
2121
+ def _note_content_build_epoch(self) -> None:
2122
+ """Record the GC freeze epoch before building the first mounted tool widget.
2123
+
2124
+ Read before any widget of the subtree exists, it bounds the whole subtree: an
2125
+ unchanged epoch at prune time proves no freeze captured any part of it.
2126
+ """
2127
+ if self._content_freeze_epoch is None:
2128
+ self._content_freeze_epoch = current_gc_freeze_epoch()
2129
+
2130
+ def _young_content_watch(self, content: Widget) -> GcRemovedContent | None:
2131
+ """Describe the mounted content about to be pruned if no freeze has captured it.
2132
+
2133
+ Returns None when the epoch moved since the oldest child was built (or was never
2134
+ recorded), so the prune keeps its idle full reclaim. Otherwise the coordinator
2135
+ weakly watches every node of the subtree and reclaims only if one outlives the next
2136
+ freezing action's collection.
2137
+ """
2138
+ built_at_epoch = self._content_freeze_epoch
2139
+ if built_at_epoch is None or built_at_epoch != current_gc_freeze_epoch():
2140
+ return None
2141
+ return GcRemovedContent.watch(
2142
+ built_at_epoch=built_at_epoch,
2143
+ nodes=(node for child in content.children for node in child.walk_children(with_self=True)),
2144
+ )
2145
+
2146
+ def _release_expensive_tool_content(self) -> None:
2147
+ """Let child tool widgets drop expensive mounted content before pruning."""
2148
+ for tc in self._tools.values():
2149
+ if isinstance(tc, _ToolCollapsedContentReleasable):
2150
+ tc.release_collapsed_content()
2151
+
2152
+ async def _release_completed_tool_widgets(self) -> None:
2153
+ """Prune completed tool widget subtrees while the group is collapsed."""
2154
+ async with self._content_structure_lock:
2155
+ await self._release_completed_tool_widgets_locked()
2156
+
2157
+ def _completed_widget_pruning_supported(self) -> bool:
2158
+ """Return whether every renderer in this group has restorable state."""
2159
+ return not any(
2160
+ record is not None and record.tool_kind == KIND_SUB_AGENT for record in self._tool_records.values()
2161
+ )
2162
+
2163
+ async def _release_completed_tool_widgets_locked(self) -> None:
2164
+ """Prune completed widgets while holding the structure lock."""
2165
+ if not self.collapsed or not self.all_complete or not self._content_mounted:
2166
+ return
2167
+ # SubAgentToolCall owns presentation state that _ToolRecord does not
2168
+ # snapshot (progress totals, compaction counts, and retry/pause
2169
+ # history). Pruning is currently all-or-nothing for the content
2170
+ # subtree, so retain the whole mixed group until sub-agent cards gain
2171
+ # a complete compact-display-state protocol.
2172
+ if not self._completed_widget_pruning_supported():
2173
+ return
2174
+ with suppress(Exception):
2175
+ self._release_expensive_tool_content()
2176
+ content = self.query_one("#tg-content")
2177
+ if not content.children:
2178
+ self._content_mounted = False
2179
+ self._tools = {}
2180
+ self._content_freeze_epoch = None
2181
+ return
2182
+ young_content = self._young_content_watch(content)
2183
+ await content.remove_children()
2184
+ # Textual's FIFO arrangement cache keeps prior child placements
2185
+ # across node-version changes. Drop those detached placements before
2186
+ # reporting the removal so the removed subtree is no longer
2187
+ # reachable from this permanently mounted container.
2188
+ content._clear_arrangement_cache()
2189
+ # Refresh NodeList's cached displayed-child view. A hidden
2190
+ # container is not laid out again, so Textual would otherwise keep
2191
+ # the removed widget list reachable until a later expansion.
2192
+ _ = content.displayed_children
2193
+ self._content_mounted = False
2194
+ self._tools = {}
2195
+ self._content_freeze_epoch = None
2196
+ # A subtree no freeze has captured is young garbage: the coordinator reclaims
2197
+ # only if it outlives the next freezing action's collection, instead of
2198
+ # rescanning the whole heap for it.
2199
+ self.post_message(
2200
+ GcReclaimRequested(GcReclaimReason.STABLE_CONTENT_REMOVED, prompt=False, removed=young_content)
2201
+ )
2202
+ if not self.collapsed or not self.all_complete:
2203
+ await self._restore_completed_tool_widgets_locked(allow_collapsed=not self.all_complete)
2204
+
2205
+ async def _restore_completed_tool_widgets(self, *, allow_collapsed: bool = False) -> None:
2206
+ """Rebuild completed tool widgets when a collapsed group is expanded."""
2207
+ async with self._content_structure_lock:
2208
+ await self._restore_completed_tool_widgets_locked(allow_collapsed=allow_collapsed)
2209
+
2210
+ async def _restore_completed_tool_widgets_locked(self, *, allow_collapsed: bool = False) -> None:
2211
+ """Rebuild tool widgets while holding the structure lock."""
2212
+ if not self._accepts_tool_mounts() or (self.collapsed and not allow_collapsed) or self._content_mounted:
2213
+ return
2214
+ from chrys.app.tui.widgets.chat.tool_renderers import create_tool_widget
2215
+
2216
+ content = self.query_one("#tg-content")
2217
+ rebuilt: dict[str, Widget] = {}
2218
+ records = list(self._tool_records.values())
2219
+ if records:
2220
+ self._note_content_build_epoch()
2221
+ widgets: list[Widget] = []
2222
+ for record in records:
2223
+ if record.provider_hosted:
2224
+ tc = create_tool_widget(
2225
+ record.call_id,
2226
+ record.tool_name,
2227
+ record.tool_kind,
2228
+ record.args_summary,
2229
+ args=record.args,
2230
+ provider_hosted=True,
2231
+ hosted_family=record.hosted_family,
2232
+ provider=record.provider,
2233
+ provider_item_type=record.provider_item_type,
2234
+ provider_status=record.provider_status,
2235
+ provider_call_id=record.provider_call_id,
2236
+ canonical_status=record.canonical_status,
2237
+ artifacts=record.artifacts,
2238
+ )
2239
+ else:
2240
+ tc = create_tool_widget(
2241
+ record.call_id,
2242
+ record.tool_name,
2243
+ record.tool_kind,
2244
+ record.args_summary,
2245
+ args=record.args,
2246
+ )
2247
+ self._configure_tool_widget(tc)
2248
+ rebuilt[record.call_id] = tc
2249
+ widgets.append(tc)
2250
+ if widgets:
2251
+ pending_mount = content.mount(*widgets)
2252
+ # Textual registers every widget synchronously before returning the
2253
+ # awaitable. Publish the matching map/state in the same critical
2254
+ # section so no caller can start a second full restore.
2255
+ self._tools = rebuilt
2256
+ self._content_mounted = True
2257
+ await pending_mount
2258
+ else:
2259
+ self._tools = rebuilt
2260
+ self._content_mounted = True
2261
+ for record in records:
2262
+ tc = rebuilt[record.call_id]
2263
+ self._apply_tool_record_state(record, tc)
2264
+ if self.collapsed:
2265
+ await self._release_completed_tool_widgets_locked()
2266
+ return
2267
+ self._schedule_pending_content_mounts(base_subtree_restored=True)
2268
+ if self.collapsed:
2269
+ await self._release_completed_tool_widgets_locked()
2270
+
2271
+ def _configure_tool_widget(self, widget: Widget) -> None:
2272
+ if self._tool_widget_configurator is not None:
2273
+ self._tool_widget_configurator(widget)
2274
+
2275
+ def _apply_tool_record_state(self, record: _ToolRecord, tc: Widget) -> None:
2276
+ """Apply authoritative logical state to a newly mounted renderer."""
2277
+ if isinstance(tc, BaseToolCard):
2278
+ tc.metadata = dict(record.metadata or {})
2279
+ if record.display_state is not None and isinstance(tc, _ToolDisplayStateRestorable):
2280
+ tc.restore_compact_display_state(record.display_state)
2281
+ renderer = _completion_renderer(tc)
2282
+ duration_rendered = False
2283
+ # canonical_status covers status-only hosted completions: their
2284
+ # completed_via_result flag is deliberately reset to let a late
2285
+ # terminal result still land, so it cannot carry rebuild state.
2286
+ if record.completed_via_result or record.canonical_status == "completed":
2287
+ renderer.set_complete(
2288
+ record.result,
2289
+ record.duration_ms,
2290
+ image_contents=record.image_contents,
2291
+ file_snapshot=record.file_snapshot,
2292
+ approval=record.approval,
2293
+ metadata=record.metadata,
2294
+ artifacts=record.artifacts,
2295
+ lazy=record.lazy,
2296
+ )
2297
+ # Completion renderers include non-zero durations in their own
2298
+ # labels. Zero-duration spans still need the replay-only suffix.
2299
+ duration_rendered = record.duration_ms != 0
2300
+ elif record.status == "error":
2301
+ if isinstance(tc, _ToolErrorPayloadRestorable):
2302
+ tc.restore_error_payload(
2303
+ record.result,
2304
+ image_contents=record.image_contents,
2305
+ metadata=record.metadata,
2306
+ artifacts=record.artifacts,
2307
+ )
2308
+ else:
2309
+ renderer.set_error(record.result)
2310
+ if record.replay_timing and (record.timestamp or record.duration_known):
2311
+ with suppress(NoMatches):
2312
+ tc.query_one(ToolCardHeader).append_replay_timing(
2313
+ timestamp=record.timestamp,
2314
+ duration_ms=record.duration_ms,
2315
+ show_duration=record.duration_known and not duration_rendered,
2316
+ )
2317
+
2318
+ def _cancel_pending_content_mounts(self) -> None:
2319
+ """Cancel deferred content work for this group."""
2320
+ worker = self._pending_content_worker
2321
+ if worker is None:
2322
+ return
2323
+ self._pending_content_worker = None
2324
+ with suppress(Exception):
2325
+ worker.cancel()
2326
+
2327
+ def _schedule_pending_content_mounts(self, *, base_subtree_restored: bool = False) -> None:
2328
+ """Start deferred tool content mounting outside the expand callback."""
2329
+ if not self._accepts_tool_mounts() or self.collapsed or not self._content_mounted:
2330
+ return
2331
+ self._cancel_pending_content_mounts()
2332
+ # A factory, not a coroutine: an exclusive group cancels a worker that never
2333
+ # started, and only a coroutine created at start leaves nothing unawaited.
2334
+ self._pending_content_worker = self.run_worker(
2335
+ partial(self._mount_pending_diffs, base_subtree_restored=base_subtree_restored),
2336
+ group=f"tool-group-{id(self)}-pending-content",
2337
+ exclusive=True,
2338
+ exit_on_error=False,
2339
+ )
2340
+
2341
+ async def _mount_pending_diffs(self, *, base_subtree_restored: bool = False) -> None:
2342
+ """Mount deferred expensive content for tools that used lazy=True."""
2343
+ if not self._accepts_tool_mounts():
2344
+ return
2345
+ pending_tools = [
2346
+ tc
2347
+ for tc in list(self._tools.values())
2348
+ if isinstance(tc, _ToolPendingContentMountable | _ToolPendingDiffMountable)
2349
+ ]
2350
+ if not pending_tools:
2351
+ if base_subtree_restored and not self.collapsed and self._content_mounted:
2352
+ self.post_message(GcAbsorbRequested(GcAbsorbReason.STABLE_CONTENT_MOUNTED))
2353
+ return
2354
+
2355
+ semaphore = asyncio.Semaphore(_PENDING_CONTENT_MOUNT_CONCURRENCY)
2356
+
2357
+ async def mount_one(tc: Widget) -> bool:
2358
+ async with semaphore:
2359
+ if not self._accepts_tool_mounts() or self.collapsed or not self._content_mounted:
2360
+ return False
2361
+ if isinstance(tc, _ToolPendingContentMountable):
2362
+ return await tc.mount_pending_content()
2363
+ if isinstance(tc, _ToolPendingDiffMountable):
2364
+ return await tc.mount_diff_if_pending()
2365
+ return False
2366
+
2367
+ tasks = [asyncio.create_task(mount_one(tc)) for tc in pending_tools]
2368
+ mounted_content = False
2369
+ try:
2370
+ for task in asyncio.as_completed(tasks):
2371
+ mounted_content = await task or mounted_content
2372
+ if self.collapsed or not self._content_mounted:
2373
+ for pending in tasks:
2374
+ if not pending.done():
2375
+ pending.cancel()
2376
+ break
2377
+ except asyncio.CancelledError:
2378
+ for task in tasks:
2379
+ task.cancel()
2380
+ raise
2381
+ finally:
2382
+ await asyncio.gather(*tasks, return_exceptions=True)
2383
+ if (base_subtree_restored or mounted_content) and not self.collapsed and self._content_mounted:
2384
+ self.post_message(GcAbsorbRequested(GcAbsorbReason.STABLE_CONTENT_MOUNTED))
2385
+
2386
+ def update_tool_progress(
2387
+ self,
2388
+ call_id: str,
2389
+ lines: list[str],
2390
+ *,
2391
+ image_contents: list[Any] | None = None,
2392
+ snapshot_metadata: dict[str, Any] | None = None,
2393
+ provider_status: str = "",
2394
+ ) -> None:
2395
+ """Forward streaming progress lines to a running tool widget."""
2396
+ record = self._tool_records.get(call_id)
2397
+ if record is not None:
2398
+ if record.canonical_status in _TERMINAL_CANONICAL_STATUSES:
2399
+ logger.debug("Ignoring late progress for terminal tool call %s", call_id)
2400
+ return
2401
+ record.image_contents = list(image_contents or record.image_contents)
2402
+ record.metadata = {**(record.metadata or {}), **(snapshot_metadata or {})}
2403
+ record.provider_status = provider_status or record.provider_status
2404
+ tc = self._tools.get(call_id)
2405
+ if isinstance(tc, _ToolProgressUpdatable):
2406
+ if isinstance(tc, BaseToolCard):
2407
+ tc.image_contents = list(image_contents or tc.image_contents)
2408
+ tc.metadata = {**(tc.metadata or {}), **(snapshot_metadata or {})}
2409
+ tc.provider_status = provider_status or tc.provider_status
2410
+ tc.update_progress(lines)
2411
+ self._capture_tool_display_state(call_id, tc)
2412
+
2413
+ def update_tool_status(
2414
+ self,
2415
+ call_id: str,
2416
+ status: str,
2417
+ *,
2418
+ provider_status: str = "",
2419
+ metadata: dict[str, Any] | None = None,
2420
+ ) -> None:
2421
+ """Apply one monotonic canonical lifecycle transition."""
2422
+ record = self._tool_records.get(call_id)
2423
+ if record is None:
2424
+ return
2425
+ if record.canonical_status in _TERMINAL_CANONICAL_STATUSES:
2426
+ logger.debug("Ignoring late status %s for terminal tool call %s", status, call_id)
2427
+ return
2428
+ if record.canonical_status == "running" and status == "pending":
2429
+ logger.debug("Ignoring regressive pending status for running tool call %s", call_id)
2430
+ return
2431
+ record.provider_status = provider_status or record.provider_status
2432
+ record.metadata = {**(record.metadata or {}), **(metadata or {})}
2433
+ tc = self._tools.get(call_id)
2434
+ if isinstance(tc, BaseToolCard):
2435
+ tc.provider_status = record.provider_status
2436
+ tc.metadata = dict(record.metadata or {})
2437
+ if status not in _TERMINAL_CANONICAL_STATUSES:
2438
+ record.canonical_status = status
2439
+ if isinstance(tc, BaseToolCard):
2440
+ tc.canonical_status = status
2441
+ if isinstance(tc, _HostedStatusUpdatable):
2442
+ tc.update_hosted_status(status, record.provider_status)
2443
+ if tc is not None:
2444
+ tc.refresh()
2445
+ return
2446
+ result_text = str((metadata or {}).get("result_text", ""))
2447
+ if status == "completed":
2448
+ self.complete_tool(
2449
+ call_id,
2450
+ result_text,
2451
+ image_contents=record.image_contents,
2452
+ metadata=record.metadata,
2453
+ artifacts=record.artifacts,
2454
+ provider_status=record.provider_status,
2455
+ canonical_status=status,
2456
+ )
2457
+ record.completed_via_result = False
2458
+ else:
2459
+ error = result_text or ("interrupted" if status == "interrupted" else "Error: Provider tool failed")
2460
+ record.canonical_status = status
2461
+ record.status = "error"
2462
+ record.result = error
2463
+ record.completed_via_result = False
2464
+ record.nested_activities.clear()
2465
+ if tc is not None:
2466
+ if isinstance(tc, BaseToolCard):
2467
+ tc.canonical_status = status
2468
+ if isinstance(tc, _HostedStatusUpdatable):
2469
+ tc.update_hosted_status(status, record.provider_status)
2470
+ _completion_renderer(tc).set_error(error)
2471
+ self._done += 1
2472
+ self._finish_if_all_complete()
2473
+ self._update_title()
2474
+
2475
+ def on_tool_group_title_clicked(self) -> None:
2476
+ if self.collapse_locked:
2477
+ return
2478
+ self.collapsed = not self.collapsed