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,1532 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """Diff viewer screen — full-screen file change viewer with per-turn tabs.
4
+
5
+ Layout:
6
+ ┌──────────────────────────────────────────────────────────────┐
7
+ │ #app-header │
8
+ ├──────────────────────────────────────────────────────────────┤
9
+ │ TabbedContent: T2 │ T5 │ T6 (active) │
10
+ ├──────────────────────────────────────────────────────────────┤
11
+ │ ┌──────────────┬───────────────────────────────────────────┐│
12
+ │ │ TreeView │ DiffView / Message ││
13
+ │ │ (directory │ (3/4 width) ││
14
+ │ │ structure) │ ││
15
+ │ │ ─ ─ ─ ─ ─ ─ │ ││
16
+ │ │ External │ ││
17
+ │ │ (flat list) │ ││
18
+ │ └──────────────┴───────────────────────────────────────────┘│
19
+ ├──────────────────────────────────────────────────────────────┤
20
+ │ Footer: Esc Back │ Space Split View │ Ctrl+G Change List │
21
+ └──────────────────────────────────────────────────────────────┘
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import functools
27
+ import os
28
+ from collections.abc import Awaitable, Callable
29
+ from contextlib import suppress
30
+ from dataclasses import dataclass, replace
31
+ from pathlib import PurePath
32
+ from typing import TYPE_CHECKING, ClassVar
33
+
34
+ from rich.style import Style
35
+ from rich.text import Text
36
+ from textual import events, on
37
+ from textual.containers import HorizontalGroup, VerticalGroup
38
+ from textual.content import Content
39
+ from textual.css.query import NoMatches
40
+ from textual.message import Message
41
+ from textual.screen import Screen
42
+ from textual.widget import Widget
43
+ from textual.widgets import Static, TabbedContent, TabPane, Tree
44
+
45
+ from chrys.app.tui.behaviors.right_click_copy import RightClickScreenCopyMixin
46
+ from chrys.app.tui.binding_display import localized_binding
47
+ from chrys.app.tui.i18n import LocaleController, render_str, widget_localizer
48
+ from chrys.app.tui.util.diff_entries import DiffFileEntry, DiffLoadResult, entry_has_visible_change
49
+ from chrys.app.tui.util.mutation_sources import merge_mutation_source as _merge_source
50
+ from chrys.app.tui.util.removal import remove_children_shielded, remove_shielded
51
+ from chrys.app.tui.widgets import ChrysLoadingIndicator
52
+ from chrys.app.tui.widgets.chrome.footer import ChrysFooter
53
+ from chrys.foundation.i18n import DisplayPath, MessageDef, MessageRef, msg
54
+ from chrys.foundation.i18n.formatting import format_message
55
+ from chrys.foundation.models.mutation_scope import MutationScope, WorkflowRunScope
56
+ from chrys.foundation.platform.paths import display_relpath
57
+ from chrys.service.mutations.types import MutationOp, MutationProvenance, MutationSource, SnapshotSkipReason
58
+
59
+ if TYPE_CHECKING:
60
+ from textual.app import ComposeResult
61
+ from textual.widgets._tree import TreeNode
62
+
63
+ from chrys.app.tui.widgets.diff_view import DiffView
64
+ from chrys.service.state.store import StateStore
65
+
66
+
67
+ # Key used in Rich ``Style.meta`` to mark the ``[x]``/``[ ]``/``[~]``
68
+ # span of a tree node label as "click here to toggle the checkbox".
69
+ # Rich merges ``meta`` dicts on style addition, so this key survives
70
+ # Textual's line-level stylize pass — see ``CheckableTree._on_click``.
71
+ _CHECK_TOGGLE_META_KEY = "chrys_check_toggle"
72
+ _CHECK_TOGGLE_META = {_CHECK_TOGGLE_META_KEY: True}
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Constants
77
+ # ---------------------------------------------------------------------------
78
+
79
+ _BACK_BINDING = msg("tui.binding.back", fallback="Back")
80
+ _TOGGLE_SPLIT_VIEW_BINDING = msg("tui.binding.toggle_split_view", fallback="Toggle Split View")
81
+ _TOGGLE_CHANGE_LIST_BINDING = msg("tui.binding.toggle_change_list", fallback="Toggle Change List")
82
+ _DIFF_VIEWER_TITLE = msg("tui.diff.title", fallback="Diff Viewer")
83
+ _NO_FILE_CHANGES = msg("tui.diff.no_file_changes", fallback="No file changes to show")
84
+ _NO_EOL = msg("tui.diff.eol.none", fallback="No EOL")
85
+ _MIXED_EOL = msg("tui.diff.eol.mixed", fallback="Mixed")
86
+ _BADGE_MODIFIED_INTERNALLY_AND_EXTERNALLY = msg(
87
+ "tui.diff.badge.modified_internally_and_externally",
88
+ fallback="modified internally and externally",
89
+ )
90
+ _BADGE_IMPLICITLY_DETECTED = msg("tui.diff.badge.implicitly_detected", fallback="implicitly detected")
91
+ _BADGE_AUTHORSHIP_UNVERIFIED = msg("tui.diff.badge.authorship_unverified", fallback="authorship unverified")
92
+ _SKIP_FILE_TOO_LARGE = msg("tui.diff.skip_reason.file_too_large", fallback="file too large")
93
+ _SKIP_BINARY_FILE = msg("tui.diff.skip_reason.binary_file", fallback="binary file")
94
+ _SKIP_UNREADABLE = msg("tui.diff.skip_reason.unreadable", fallback="file unreadable")
95
+ _CHANGE_LIST = msg("tui.diff.change_list", fallback="Change List")
96
+ _SELECT_FILE = msg("tui.diff.select_file", fallback="Select a file to view changes")
97
+ _NO_BACKUP = msg("tui.diff.no_backup", fallback=" (no backup)")
98
+ _CONTENT_NOT_BACKED_UP = msg(
99
+ "tui.diff.content_not_backed_up",
100
+ fallback="Content not backed up ({reason}): {path}. No diff to display.",
101
+ )
102
+ _BINARY_FILE = msg("tui.diff.binary_file", fallback="Binary file: {path}")
103
+ _FILE_MOVED = msg("tui.diff.file_moved", fallback="File moved: {old_path} → {new_path}")
104
+ _ONLY_LINE_ENDINGS_CHANGED = msg(
105
+ "tui.diff.only_line_endings_changed",
106
+ fallback="Only line endings changed. No line content changes to display.",
107
+ )
108
+ _ONLY_BYTE_REPRESENTATION_CHANGED = msg(
109
+ "tui.diff.only_byte_representation_changed",
110
+ fallback="Only file encoding or byte-level representation changed. No line content changes to display.",
111
+ )
112
+ _DIFF_VIEWER_SESSION_TITLE = msg(
113
+ "tui.diff.session_title",
114
+ fallback="Diff Viewer - Session: {session_id}",
115
+ )
116
+ _LOAD_ERROR = msg(
117
+ "tui.diff.load_error",
118
+ fallback="Error: unable to load diff viewer — {error}",
119
+ )
120
+ _EXTERNAL_TREE = msg("tui.diff.external_tree", fallback="External")
121
+ _TURN_TAB = msg("tui.diff.tab.turn", fallback="T{turn_id}")
122
+ _RUN_TAB = msg("tui.diff.tab.run", fallback="Run {number}")
123
+ _ALL_TAB = msg("tui.diff.tab.all", fallback="All")
124
+
125
+ _OP_SEGMENTS: dict[MutationOp, list[tuple[str, str]]] = {
126
+ MutationOp.MODIFY: [("[", "bold orange1"), ("+", "bold green"), ("-", "bold red"), ("]", "bold orange1")],
127
+ MutationOp.CREATE: [("[+]", "bold green")],
128
+ MutationOp.DELETE: [("[-]", "bold red")],
129
+ MutationOp.MOVE: [("[→]", "bold cyan")],
130
+ }
131
+
132
+ # Checkbox prefixes used when DiffPeriodPane runs in checkable mode
133
+ # (currently only from the rollback modal). Tri-state on folders:
134
+ # [x] all descendants checked, [ ] none, [~] mixed. Each entry is a
135
+ # ``(glyph, base_style)`` pair; the label builder merges in the
136
+ # click-toggle meta so the span is identifiable as a click target.
137
+ _CHECK_SEGMENTS: dict[str, tuple[str, str]] = {
138
+ "on": ("[x]", "bold green"),
139
+ "off": ("[ ]", "bold white"),
140
+ "mixed": ("[~]", "bold yellow"),
141
+ }
142
+
143
+
144
+ def _entry_is_eol_only_change(entry: DiffFileEntry) -> bool:
145
+ """Return whether a MODIFY changed only line endings / final newline."""
146
+ return (
147
+ not entry.is_binary
148
+ and entry.operation is MutationOp.MODIFY
149
+ and entry.before_text != entry.after_text
150
+ and entry.before_text.splitlines() == entry.after_text.splitlines()
151
+ )
152
+
153
+
154
+ def _entry_is_metadata_only_change(entry: DiffFileEntry) -> bool:
155
+ """Return whether bytes changed but decoded line content did not."""
156
+ return (
157
+ not entry.is_binary
158
+ and entry.operation is MutationOp.MODIFY
159
+ and entry.bytes_changed
160
+ and entry.before_text == entry.after_text
161
+ )
162
+
163
+
164
+ def _text_eol_label(
165
+ text: str,
166
+ render_message: Callable[[MessageRef], str] = format_message,
167
+ ) -> str:
168
+ """Return a compact label for the line endings present in ``text``."""
169
+ crlf = text.count("\r\n")
170
+ cr = text.count("\r") - crlf
171
+ lf = text.count("\n") - crlf
172
+ kinds = int(crlf > 0) + int(cr > 0) + int(lf > 0)
173
+ if kinds == 0:
174
+ return render_message(_NO_EOL.bind())
175
+ if kinds > 1:
176
+ return render_message(_MIXED_EOL.bind())
177
+ if crlf:
178
+ return "CRLF"
179
+ if cr:
180
+ return "CR"
181
+ return "LF"
182
+
183
+
184
+ def _entry_eol_text(entry: DiffFileEntry) -> str:
185
+ """Return the entry text that represents the displayed file state."""
186
+ if entry.operation is MutationOp.DELETE:
187
+ return entry.before_text
188
+ return entry.after_text
189
+
190
+
191
+ # One-line legend per provenance badge, shown in the diff-content
192
+ # subtitle so the markers are self-explanatory in place.
193
+ _BADGE_LEGENDS: dict[str, MessageDef] = {
194
+ "!": _BADGE_MODIFIED_INTERNALLY_AND_EXTERNALLY,
195
+ "?": _BADGE_IMPLICITLY_DETECTED,
196
+ "~": _BADGE_AUTHORSHIP_UNVERIFIED,
197
+ }
198
+ _BADGE_LEGEND_ORDER = "!?~"
199
+
200
+
201
+ def _entry_content_subtitle(
202
+ entry: DiffFileEntry,
203
+ render_message: Callable[[MessageRef], str] = format_message,
204
+ ) -> Text:
205
+ """Build the diff-content subtitle: badge legend + EOL and encoding.
206
+
207
+ Format: ``!: legend - ?: legend - ~: legend - LF - UTF-8``, with
208
+ only the badges the entry actually carries.
209
+ """
210
+ encoding = entry.encoding.upper()
211
+ tail = encoding if entry.is_binary else f"{_text_eol_label(_entry_eol_text(entry), render_message)} - {encoding}"
212
+ parts: list[Text] = []
213
+ for marker, style in sorted(_entry_source_suffix(entry), key=lambda ms: _BADGE_LEGEND_ORDER.index(ms[0])):
214
+ legend = Text()
215
+ legend.append(marker, style)
216
+ legend.append(f": {render_message(_BADGE_LEGENDS[marker].bind())}")
217
+ parts.append(legend)
218
+ parts.append(Text(tail))
219
+ return Text(" - ").join(parts)
220
+
221
+
222
+ # Human-readable labels for ``DiffFileEntry.content_omitted`` values
223
+ # (``SnapshotSkipReason`` — why SnapshotPolicy withheld the backup).
224
+ _SKIP_REASON_LABELS: dict[str, MessageDef] = {
225
+ SnapshotSkipReason.TOO_LARGE.value: _SKIP_FILE_TOO_LARGE,
226
+ SnapshotSkipReason.BINARY.value: _SKIP_BINARY_FILE,
227
+ SnapshotSkipReason.UNREADABLE.value: _SKIP_UNREADABLE,
228
+ }
229
+
230
+
231
+ def _entry_source_suffix(entry: DiffFileEntry) -> list[tuple[str, str]]:
232
+ """Provenance badges rendered right-aligned at the end of a leaf row.
233
+
234
+ ``?`` implicit detection, ``~`` window-diff inference (assumed
235
+ provenance), ``!`` contested — a peer chrys session also wrote the
236
+ path, so disk may differ from "our" after state and rollback will
237
+ exclude it. ``!`` is dim while the row's own attribution is itself
238
+ inference (``?``/``~`` rows) and bold yellow on proven rows, where
239
+ it flags a hard both-sessions-wrote conflict.
240
+ """
241
+ badges: list[tuple[str, str]] = []
242
+ implicit = entry.source == MutationSource.IMPLICIT.value
243
+ if implicit:
244
+ badges.append(("?", "bold yellow"))
245
+ elif entry.inferred:
246
+ badges.append(("~", "dim"))
247
+ if entry.contested:
248
+ badges.append(("!", "dim" if (implicit or entry.inferred) else "bold yellow"))
249
+ return badges
250
+
251
+
252
+ def _checkbox_text(state: str) -> Text:
253
+ """Build the Rich ``Text`` for a checkbox glyph carrying toggle meta.
254
+
255
+ The meta key is inspected by :class:`CheckableTree._on_click` to
256
+ detect mouse clicks that land on the ``[x]`` span so they can be
257
+ routed to a toggle action instead of the default node-select.
258
+ """
259
+ glyph, style_str = _CHECK_SEGMENTS[state]
260
+ return Text(glyph, style=Style.parse(style_str) + Style(meta=_CHECK_TOGGLE_META))
261
+
262
+
263
+ @dataclass
264
+ class _FolderNodeData:
265
+ """Sentinel attached to directory tree nodes so we can distinguish
266
+ them from leaf (file) nodes without resorting to label parsing."""
267
+
268
+ name: str
269
+
270
+
271
+ class CheckableTree(Tree):
272
+ """``Tree`` subclass that posts a :class:`CheckToggled` message when
273
+ the user clicks on a checkbox span in a node label.
274
+
275
+ The checkbox span is identified by the
276
+ :data:`_CHECK_TOGGLE_META_KEY` entry in the Rich ``Style.meta`` of
277
+ the clicked character. When detected we:
278
+
279
+ 1. Move ``cursor_line`` to the clicked row (for visual feedback —
280
+ the cursor "follows" the checkbox the user toggled).
281
+ 2. Post the message so the owning widget can mutate its own
282
+ selection state.
283
+ 3. Stop propagation so the base Tree does **not** also run
284
+ ``select_cursor`` — we don't want a checkbox click to also
285
+ switch which diff is shown in the preview pane.
286
+
287
+ Clicks that land outside the checkbox span fall through to the
288
+ default Tree click handler (select + show diff / expand / collapse).
289
+ """
290
+
291
+ class CheckToggled(Message):
292
+ """Posted when the user clicks a checkbox span."""
293
+
294
+ def __init__(self, node: TreeNode) -> None: # type: ignore[type-arg]
295
+ super().__init__()
296
+ self.node = node
297
+
298
+ async def _on_click(self, event: events.Click) -> None:
299
+ # ``prevent_default`` is critical here: Textual dispatches
300
+ # ``_on_click`` for *every* class in the MRO, so without it the
301
+ # base ``Tree._on_click`` would run in addition to ours. That
302
+ # caused two bugs:
303
+ # 1. Clicking ``[x]`` also fired ``select_cursor`` →
304
+ # ``NodeSelected`` → ``_expand_node_on_select`` (auto_expand
305
+ # is True by default), collapsing/expanding the folder.
306
+ # 2. Clicking the ▼/▶ guide toggled the node twice — once
307
+ # via our explicit ``super()._on_click`` call below and
308
+ # once via the MRO dispatch — so the visible state never
309
+ # changed.
310
+ # ``event.stop()`` only halts bubbling to parents; it does not
311
+ # stop sibling MRO handlers from being invoked on this widget.
312
+ event.prevent_default()
313
+ meta = event.style.meta
314
+ line = meta.get("line")
315
+ if meta.get(_CHECK_TOGGLE_META_KEY) and line is not None:
316
+ node = self.get_node_at_line(line)
317
+ if node is not None:
318
+ # Move the cursor so the user sees which row they just
319
+ # toggled; skip scrolling so it's a pure visual hint.
320
+ self.cursor_line = line
321
+ self.post_message(self.CheckToggled(node))
322
+ event.stop()
323
+ return
324
+ # Not a checkbox click — let the base class do its thing
325
+ # (select + fire NodeSelected, or expand/collapse the guide).
326
+ await super()._on_click(event)
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Data loading helpers
331
+ # ---------------------------------------------------------------------------
332
+
333
+
334
+ def _build_period_entries(tracker, turn, cwd: str) -> list[DiffFileEntry]:
335
+ """Build DiffFileEntry list for a single turn from a deserialized tracker."""
336
+ from chrys.foundation.text.encoding import EncodingDetector, decode_bytes
337
+
338
+ last_op: dict[str, MutationOp] = {}
339
+ old_paths: dict[str, str | None] = {}
340
+ sources: dict[str, str] = {}
341
+ for m in turn.mutations:
342
+ # Foreign rows are excluded from the summary fold below; keep
343
+ # this metadata consistent or a peer's op/old_path/source leaks
344
+ # onto a row whose hashes came from our rows only.
345
+ if m.provenance is MutationProvenance.FOREIGN:
346
+ continue
347
+ last_op[m.path] = m.operation
348
+ old_paths[m.path] = m.old_path
349
+ sources[m.path] = _merge_source(sources.get(m.path, ""), m.source.value)
350
+ if m.old_path:
351
+ sources[m.old_path] = _merge_source(sources.get(m.old_path, ""), m.source.value)
352
+
353
+ detector = EncodingDetector()
354
+ file_summary = {
355
+ path: diff
356
+ for path, diff in tracker.get_period_file_summary(turn.scope).items()
357
+ # A skip-marked diff is not net-zero even with (None, None)
358
+ # hashes — the content exists but was never backed up.
359
+ if not diff.is_net_zero or diff.content_unavailable or last_op.get(path) is MutationOp.MOVE
360
+ }
361
+ if not file_summary:
362
+ return []
363
+
364
+ # A MOVE card already represents both paths. Keep the complete two-path
365
+ # summary for rollback/analytics, but avoid a duplicate DELETE card unless
366
+ # the source was subsequently recreated or the destination no longer moves.
367
+ moved_sources = {
368
+ old_path
369
+ for path, old_path in old_paths.items()
370
+ if old_path is not None and path in file_summary and last_op.get(path) is MutationOp.MOVE
371
+ }
372
+ entries: list[DiffFileEntry] = []
373
+ store = tracker.store
374
+ for file_path, diff in file_summary.items():
375
+ if file_path in moved_sources and diff.after is None and diff.after_skip is None:
376
+ continue
377
+ # Content withheld by SnapshotPolicy (too large / binary): no
378
+ # blobs to render, but the mutation still happened — keep the
379
+ # row visible with a ``content_omitted`` marker (matching the
380
+ # live /diff behavior) instead of silently dropping the event.
381
+ # MOVE keeps its plain rename row — that info is content-free.
382
+ content_omitted = ""
383
+ if diff.content_unavailable and last_op.get(file_path) is not MutationOp.MOVE:
384
+ skip = diff.after_skip or diff.before_skip
385
+ content_omitted = skip.value if skip else ""
386
+
387
+ before_bytes = store.read_blob(diff.before) if diff.before else None
388
+ after_bytes = store.read_blob(diff.after) if diff.after else None
389
+
390
+ binary = bool(
391
+ (before_bytes and EncodingDetector.looks_binary(before_bytes))
392
+ or (after_bytes and EncodingDetector.looks_binary(after_bytes))
393
+ )
394
+ before_text = "" if binary or before_bytes is None else decode_bytes(before_bytes)
395
+ after_text = "" if binary or after_bytes is None else decode_bytes(after_bytes)
396
+
397
+ # Detect encoding from the latest available content
398
+ enc = "utf-8"
399
+ content_for_detect = after_bytes or before_bytes
400
+ if content_for_detect and not binary:
401
+ result = detector.detect_from_bytes(content_for_detect)
402
+ enc = result.encoding or "utf-8"
403
+
404
+ op = last_op.get(file_path, MutationOp.MODIFY)
405
+ rel = display_relpath(file_path, cwd)
406
+
407
+ entry = DiffFileEntry(
408
+ path=file_path,
409
+ rel_path=rel,
410
+ operation=op,
411
+ old_path=old_paths.get(file_path),
412
+ before_text=before_text,
413
+ after_text=after_text,
414
+ is_binary=binary,
415
+ encoding=enc,
416
+ bytes_changed=not diff.is_net_zero or bool(content_omitted),
417
+ before_hash=diff.before,
418
+ after_hash=diff.after,
419
+ source=sources.get(file_path, ""),
420
+ content_omitted=content_omitted,
421
+ contested=diff.contested,
422
+ inferred=diff.inferred,
423
+ )
424
+ if entry_has_visible_change(entry):
425
+ entries.append(entry)
426
+
427
+ entries.sort(key=lambda e: e.rel_path)
428
+ return entries
429
+
430
+
431
+ def _build_session_entries(tracker, cwd: str) -> list[DiffFileEntry]:
432
+ """Build DiffFileEntry list for the net session-wide changeset.
433
+
434
+ Aggregates mutations across all turns: each file's ``before_text`` is
435
+ its pre-session state (from the earliest turn that touched it) and
436
+ ``after_text`` is its latest state after the last mutation.
437
+ Net-zero churn is already filtered by ``get_session_file_summary``.
438
+ """
439
+ from chrys.foundation.text.encoding import EncodingDetector, decode_bytes
440
+
441
+ detector = EncodingDetector()
442
+ file_summary = tracker.get_session_file_summary()
443
+ if not file_summary:
444
+ return []
445
+
446
+ # Track last-seen old_path across the session (for MOVE display).
447
+ # Iterate in turn_id order so "last-seen" is chronological — turns
448
+ # can be appended with non-monotonic ids via session restore/retry.
449
+ old_paths: dict[str, str | None] = {}
450
+ sources: dict[str, str] = {}
451
+ for turn in sorted(tracker.get_all_periods(), key=lambda period: period.period_index):
452
+ for m in turn.mutations:
453
+ # Match the summary fold: foreign rows must not contribute
454
+ # a MOVE's old_path or their source marker to our entries.
455
+ if m.provenance is MutationProvenance.FOREIGN:
456
+ continue
457
+ sources[m.path] = _merge_source(sources.get(m.path, ""), m.source.value)
458
+ if m.old_path:
459
+ old_paths[m.path] = m.old_path
460
+ sources[m.old_path] = _merge_source(sources.get(m.old_path, ""), m.source.value)
461
+
462
+ entries: list[DiffFileEntry] = []
463
+ store = tracker.store
464
+ for file_path, diff in file_summary.items():
465
+ # Content withheld by SnapshotPolicy on either side: keep the
466
+ # row (it IS a net change — fully-skipped files are already
467
+ # netted out by the tracker) but mark it so the pane renders a
468
+ # message instead of an untruthful empty diff.
469
+ content_omitted = ""
470
+ if diff.content_unavailable:
471
+ skip = diff.after_skip or diff.before_skip
472
+ content_omitted = skip.value if skip else ""
473
+
474
+ before_bytes = store.read_blob(diff.before) if diff.before else None
475
+ after_bytes = store.read_blob(diff.after) if diff.after else None
476
+
477
+ binary = bool(
478
+ (before_bytes and EncodingDetector.looks_binary(before_bytes))
479
+ or (after_bytes and EncodingDetector.looks_binary(after_bytes))
480
+ )
481
+ before_text = "" if binary or before_bytes is None else decode_bytes(before_bytes)
482
+ after_text = "" if binary or after_bytes is None else decode_bytes(after_bytes)
483
+
484
+ # Derive net op from skip-aware existence (a side with a
485
+ # withheld backup still exists on disk). Net-zero
486
+ # (before == after) is already filtered by the tracker, so if
487
+ # both hashes are present here, they necessarily differ → MODIFY.
488
+ # This is more accurate than the last-recorded op, which for a
489
+ # pre-existing-file DELETE→CREATE sequence would be CREATE but
490
+ # the true net effect is MODIFY.
491
+ if not diff.before_exists and diff.after_exists:
492
+ op = MutationOp.CREATE
493
+ elif diff.before_exists and not diff.after_exists:
494
+ op = MutationOp.DELETE
495
+ else:
496
+ op = MutationOp.MODIFY
497
+
498
+ enc = "utf-8"
499
+ content_for_detect = after_bytes or before_bytes
500
+ if content_for_detect and not binary:
501
+ det = detector.detect_from_bytes(content_for_detect)
502
+ enc = det.encoding or "utf-8"
503
+
504
+ rel = display_relpath(file_path, cwd)
505
+
506
+ entry = DiffFileEntry(
507
+ path=file_path,
508
+ rel_path=rel,
509
+ operation=op,
510
+ old_path=old_paths.get(file_path),
511
+ before_text=before_text,
512
+ after_text=after_text,
513
+ is_binary=binary,
514
+ encoding=enc,
515
+ bytes_changed=True,
516
+ before_hash=diff.before,
517
+ after_hash=diff.after,
518
+ source=sources.get(file_path, ""),
519
+ content_omitted=content_omitted,
520
+ contested=diff.contested,
521
+ inferred=diff.inferred,
522
+ )
523
+ if entry_has_visible_change(entry):
524
+ entries.append(entry)
525
+
526
+ entries.sort(key=lambda e: e.rel_path)
527
+ return entries
528
+
529
+
530
+ def load_diff_entries_by_period(
531
+ state_store: StateStore,
532
+ session_id: str,
533
+ cwd: str,
534
+ *,
535
+ mutations_data: dict | None = None,
536
+ ) -> DiffLoadResult:
537
+ """Build execution-scoped and session-wide diffs from persisted state."""
538
+ import json
539
+
540
+ from chrys.service.mutations.store import SnapshotStore
541
+ from chrys.service.mutations.tracker import MutationTracker
542
+ from chrys.service.state.store import SESSION_FILE_NAME
543
+
544
+ try:
545
+ path = state_store.session_dir(session_id) / SESSION_FILE_NAME
546
+ if mutations_data is None:
547
+ if not path.exists():
548
+ return DiffLoadResult.empty()
549
+ raw = json.loads(path.read_text(encoding="utf-8"))
550
+ state = raw.get("state", {})
551
+ mutations_data = state.get("chrys_mutations")
552
+ if not mutations_data:
553
+ return DiffLoadResult.empty()
554
+
555
+ # ``path`` came from ``state_store._session_file(session_id)``, so
556
+ # ``path.parent`` is already the canonical session folder. No
557
+ # need to rederive it — which would re-sanitize the id and break
558
+ # for canonical (dashed) UUIDs whose folder name is the 12-char
559
+ # dashless projection, not the raw id.
560
+ tracker = MutationTracker.deserialize(mutations_data, SnapshotStore(path.parent))
561
+ except Exception:
562
+ return DiffLoadResult.empty()
563
+
564
+ periods = sorted(tracker.get_all_periods(), key=lambda period: period.period_index)
565
+ entries_by_period: dict[int, list[DiffFileEntry]] = {}
566
+ scopes: dict[int, MutationScope] = {}
567
+ for number, period in enumerate(periods, start=1):
568
+ scopes[number] = period.scope
569
+ entries = _build_period_entries(tracker, period, cwd) if period.mutations else []
570
+ if entries:
571
+ entries_by_period[number] = entries
572
+
573
+ return DiffLoadResult(
574
+ all_entries=_build_session_entries(tracker, cwd),
575
+ per_period_entries=entries_by_period,
576
+ total_periods=len(periods),
577
+ scopes=scopes,
578
+ )
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # DiffPeriodPane — per-turn content widget
583
+ # ---------------------------------------------------------------------------
584
+
585
+
586
+ def _is_within_cwd(rel_path: str) -> bool:
587
+ """Check if a relative path stays within the CWD (doesn't escape with ..)."""
588
+ return not rel_path.startswith("..") and not os.path.isabs(rel_path)
589
+
590
+
591
+ def _entry_display_key(entry: DiffFileEntry) -> str:
592
+ """Key matching where an entry renders in the change-list tree."""
593
+ display_path = entry.rel_path if _is_within_cwd(entry.rel_path) else entry.path
594
+ return os.path.normpath(display_path)
595
+
596
+
597
+ def filter_and_dedupe_entries(entries: list[DiffFileEntry]) -> list[DiffFileEntry]:
598
+ """Collapse duplicate tree rows and drop entries with no visible change."""
599
+ latest_by_key: dict[str, DiffFileEntry] = {}
600
+ key_order: list[str] = []
601
+ for entry in entries:
602
+ key = _entry_display_key(entry)
603
+ if key not in latest_by_key:
604
+ key_order.append(key)
605
+ latest_by_key[key] = entry
606
+ continue
607
+ # Builders should already aggregate by file. If an older session
608
+ # or live source still hands us duplicates, the later entry reflects
609
+ # the newest net state for that tree row. Badges are sticky
610
+ # across the collapse (any contested/inferred row marks the key).
611
+ prev = latest_by_key[key]
612
+ latest_by_key[key] = replace(
613
+ entry,
614
+ source=_merge_source(prev.source, entry.source),
615
+ contested=prev.contested or entry.contested,
616
+ inferred=prev.inferred or entry.inferred,
617
+ )
618
+ return [latest_by_key[key] for key in key_order if entry_has_visible_change(latest_by_key[key])]
619
+
620
+
621
+ def _entry_all_tab_key(entry: DiffFileEntry) -> tuple[str, str, MutationOp, str | None, str, str, str]:
622
+ """Projected entry identity for deciding whether the All tab is redundant."""
623
+ return (
624
+ entry.path,
625
+ entry.rel_path,
626
+ entry.operation,
627
+ entry.old_path,
628
+ entry.before_text,
629
+ entry.after_text,
630
+ entry.source,
631
+ )
632
+
633
+
634
+ def _should_show_all_tab(all_entries: list[DiffFileEntry], periods_data: dict[int, list[DiffFileEntry]]) -> bool:
635
+ """Return whether the session-wide All tab adds distinct information."""
636
+ if not all_entries:
637
+ return False
638
+ if len(periods_data) != 1:
639
+ return True
640
+ all_keys = [_entry_all_tab_key(entry) for entry in filter_and_dedupe_entries(all_entries)]
641
+ only_turn_keys = [
642
+ _entry_all_tab_key(entry) for entry in filter_and_dedupe_entries(next(iter(periods_data.values())))
643
+ ]
644
+ return all_keys != only_turn_keys
645
+
646
+
647
+ class DiffPeriodPane(Widget):
648
+ """Per-turn changelist + diff viewer.
649
+
650
+ Contains a directory-structured tree for workspace files, an optional
651
+ flat list for external files, and a DiffView panel on the right.
652
+ """
653
+
654
+ class SelectionChanged(Message):
655
+ """Posted whenever the set of checked leaves changes.
656
+
657
+ Only fires in checkable mode — the ``/diff`` screen never sees
658
+ this. Consumers (currently the rollback modal) use it to keep
659
+ a "Revert N files" affordance in sync with the tree. Both
660
+ totals are included so the consumer doesn't have to track
661
+ ``len(entries)`` separately.
662
+ """
663
+
664
+ def __init__(self, selected_count: int, total_count: int) -> None:
665
+ super().__init__()
666
+ self.selected_count = selected_count
667
+ self.total_count = total_count
668
+
669
+ def __init__(self, entries: list[DiffFileEntry], *, checkable: bool = False, lazy: bool = False) -> None:
670
+ super().__init__()
671
+ self._entries = filter_and_dedupe_entries(entries)
672
+ self._diff_cache: dict[str, DiffView] = {} # path → DiffView widget
673
+ self._active_path: str | None = None
674
+ self._view_overrides: dict[str, bool] = {} # path → user-set split mode
675
+ self._cwd_entries = [e for e in self._entries if _is_within_cwd(e.rel_path)]
676
+ self._ext_entries = [e for e in self._entries if not _is_within_cwd(e.rel_path)]
677
+ # Checkable mode: leaves render with a [x]/[ ] prefix and folders
678
+ # with [x]/[~]/[ ]. ``_unchecked`` stores leaf paths the user has
679
+ # deselected; default empty = all checked. Toggled in place via
680
+ # ``toggle_check_node`` and re-rendered via ``_relabel_all``.
681
+ self._checkable = checkable
682
+ self._unchecked: set[str] = set()
683
+ # path → spaces between the label body and its badge column
684
+ # (computed per tree in ``_assign_badge_pads``).
685
+ self._badge_pad: dict[str, int] = {}
686
+ self._lazy = lazy
687
+ self._initialized = False
688
+ self._initializing = False
689
+
690
+ def _render_message(self, reference: MessageRef) -> str:
691
+ return render_str(widget_localizer(self), reference)
692
+
693
+ def compose(self) -> ComposeResult:
694
+ with HorizontalGroup(classes="diff-body"):
695
+ changelist = VerticalGroup(classes="diff-changelist")
696
+ changelist.border_title = self._render_message(_CHANGE_LIST.bind())
697
+ with changelist:
698
+ # ``CheckableTree`` is a drop-in Tree that routes clicks
699
+ # on checkbox spans to a ``CheckToggled`` message. Its
700
+ # custom path is dormant unless labels carry the toggle
701
+ # meta, so using it in non-checkable mode too is safe.
702
+ workspace_tree: CheckableTree = CheckableTree("Workspace", classes="workspace-tree")
703
+ workspace_tree.show_root = False
704
+ workspace_tree.show_guides = True
705
+ workspace_tree.guide_depth = 2
706
+ yield workspace_tree
707
+ if self._ext_entries:
708
+ ext_tree: CheckableTree = CheckableTree(
709
+ self._render_message(_EXTERNAL_TREE.bind()), classes="external-tree"
710
+ )
711
+ ext_tree.show_root = True
712
+ ext_tree.show_guides = False
713
+ yield ext_tree
714
+ diff_content = VerticalGroup(classes="diff-content")
715
+ diff_content.border_title = ""
716
+ with diff_content:
717
+ yield Static(self._render_message(_SELECT_FILE.bind()), classes="diff-message")
718
+
719
+ async def on_mount(self) -> None:
720
+ if self._lazy:
721
+ return
722
+ await self.ensure_initialized()
723
+
724
+ async def ensure_initialized(self) -> None:
725
+ """Build tree contents and mount the first file diff on demand."""
726
+ if self._initialized or self._initializing:
727
+ return
728
+ self._initializing = True
729
+ try:
730
+ await self._initialize()
731
+ self._initialized = True
732
+ finally:
733
+ self._initializing = False
734
+ # Tree regions can still be 0 while building (lazy tabs mount
735
+ # before layout); re-anchor once the layout has settled.
736
+ self.call_after_refresh(self._refresh_badge_alignment)
737
+
738
+ async def _initialize(self) -> None:
739
+ self._build_workspace_tree()
740
+ if self._ext_entries:
741
+ self._build_external_tree()
742
+ # Auto-select first file leaf
743
+ ws_tree = self.query_one(".workspace-tree", Tree)
744
+ first_leaf = self._find_first_leaf(ws_tree.root)
745
+ # Only auto-show if we actually landed on a file leaf — folder
746
+ # nodes now also carry data (``_FolderNodeData``) so a plain
747
+ # ``is not None`` check would incorrectly pass a folder into
748
+ # ``_show_file``.
749
+ if first_leaf and isinstance(first_leaf.data, DiffFileEntry):
750
+ # Newly added TreeNodes get render line numbers lazily; build them
751
+ # before moving the cursor so the visible selection lands on the file.
752
+ _ = ws_tree.last_line
753
+ ws_tree.move_cursor(first_leaf)
754
+ await self._show_file(first_leaf.data)
755
+ elif self._ext_entries:
756
+ ext_tree = self.query_one(".external-tree", Tree)
757
+ if ext_tree.root.children:
758
+ first = ext_tree.root.children[0]
759
+ # See workspace-tree selection above.
760
+ _ = ext_tree.last_line
761
+ ext_tree.move_cursor(first)
762
+ if isinstance(first.data, DiffFileEntry):
763
+ await self._show_file(first.data)
764
+
765
+ @staticmethod
766
+ def _find_first_leaf(node: TreeNode) -> TreeNode | None: # type: ignore[type-arg]
767
+ """DFS to find the first leaf node carrying a ``DiffFileEntry``.
768
+
769
+ Checks the data type rather than ``is not None`` — folder nodes
770
+ also carry data (``_FolderNodeData``) in checkable mode, so we
771
+ have to discriminate or we'd pick a folder as the "first leaf".
772
+ """
773
+ if isinstance(node.data, DiffFileEntry):
774
+ return node
775
+ for child in node.children:
776
+ result = DiffPeriodPane._find_first_leaf(child)
777
+ if result is not None:
778
+ return result
779
+ return None
780
+
781
+ def _build_workspace_tree(self) -> None:
782
+ """Build directory-structured tree for CWD files."""
783
+ tree = self.query_one(".workspace-tree", Tree)
784
+ tree.clear()
785
+ dir_nodes: dict[str, TreeNode] = {} # type: ignore[type-arg]
786
+
787
+ ordered = sorted(self._cwd_entries, key=lambda e: e.rel_path)
788
+ self._assign_badge_pads([(e, (len(PurePath(e.rel_path).parts) - 1) * tree.guide_depth) for e in ordered], tree)
789
+ for entry in ordered:
790
+ # ``PurePath`` is platform-aware: parses ``os.sep`` correctly on
791
+ # both Windows and POSIX, no manual separator munging needed.
792
+ parts = PurePath(entry.rel_path).parts
793
+ if not parts:
794
+ continue
795
+
796
+ # Create/find parent directory nodes
797
+ current = tree.root
798
+ for i, part in enumerate(parts[:-1]):
799
+ key = "/".join(parts[: i + 1])
800
+ if key not in dir_nodes:
801
+ node = current.add(Text(part), expand=True, data=_FolderNodeData(name=part))
802
+ dir_nodes[key] = node
803
+ current = dir_nodes[key]
804
+
805
+ # Add file leaf (filename is derived from entry.rel_path inside _leaf_label).
806
+ current.add_leaf(self._leaf_label(entry), data=entry)
807
+
808
+ if self._checkable:
809
+ self._relabel_all()
810
+
811
+ def _build_external_tree(self) -> None:
812
+ """Build flat list for files outside CWD."""
813
+ tree = self.query_one(".external-tree", Tree)
814
+ tree.clear() # idempotent: a retried _initialize() must not stack leaves
815
+ ordered = sorted(self._ext_entries, key=lambda e: e.path)
816
+ # External tree has no intermediate folder nodes; labels use
817
+ # absolute paths (via _leaf_display). Build via the shared leaf
818
+ # helper so the checkbox prefix (when enabled) is applied
819
+ # uniformly. All leaves sit at the same depth, so the badge
820
+ # column only needs a uniform indent term.
821
+ self._assign_badge_pads([(e, tree.guide_depth) for e in ordered], tree)
822
+ for entry in ordered:
823
+ tree.root.add_leaf(self._leaf_label(entry), data=entry)
824
+ tree.root.expand()
825
+
826
+ if self._checkable:
827
+ self._relabel_all()
828
+
829
+ @staticmethod
830
+ def _leaf_display(entry: DiffFileEntry) -> str:
831
+ """Display name for a leaf: basename in the workspace tree, full
832
+ absolute path in the external tree (which has no folder nodes)."""
833
+ if _is_within_cwd(entry.rel_path):
834
+ return PurePath(entry.rel_path).name
835
+ return entry.path
836
+
837
+ def _leaf_body(self, entry: DiffFileEntry) -> Text:
838
+ """Everything left of the badge column — checkbox, op, name, notes.
839
+
840
+ When the pane runs in checkable mode the label is prefixed with
841
+ a clickable ``[x]``/``[ ]`` span (the checkbox glyph carries
842
+ ``_CHECK_TOGGLE_META`` so :class:`CheckableTree` can intercept
843
+ mouse clicks). The op badge ([+]/[-]/[→]/[+-]) follows the
844
+ checkbox so the checkbox column stays leftmost. The name is
845
+ never truncated — rows wider than the pane scroll horizontally.
846
+ """
847
+ op_segments = _OP_SEGMENTS.get(entry.operation, [("[?]", "bold white")])
848
+ parts: list[Text | tuple[str, str] | str] = []
849
+ if self._checkable:
850
+ parts += [_checkbox_text("off" if entry.path in self._unchecked else "on"), " "]
851
+ parts += [*op_segments, " ", self._leaf_display(entry)]
852
+ if entry.content_omitted:
853
+ parts.append((self._render_message(_NO_BACKUP.bind()), "dim"))
854
+ return Text.assemble(*parts)
855
+
856
+ def _leaf_label(self, entry: DiffFileEntry) -> Text:
857
+ """Leaf body plus provenance badges padded into a shared right column.
858
+
859
+ ``_assign_badge_pads`` picks the column (one cell past the
860
+ widest body in the same tree, guide indentation included) so
861
+ ``?``/``~``/``!`` line up vertically regardless of name length.
862
+ """
863
+ label = self._leaf_body(entry)
864
+ badges = _entry_source_suffix(entry)
865
+ if badges:
866
+ label.append(" " * self._badge_pad.get(entry.path, 1))
867
+ for i, (marker, style) in enumerate(badges):
868
+ if i:
869
+ label.append(" ")
870
+ label.append(marker, style)
871
+ return label
872
+
873
+ def _assign_badge_pads(self, rows: list[tuple[DiffFileEntry, int]], tree: Tree) -> None:
874
+ """Compute per-leaf padding that right-aligns badges to ``tree``'s edge.
875
+
876
+ ``rows`` pair each entry with its guide indentation (in cells)
877
+ so alignment holds across tree depths, not just label-local.
878
+ Badge blocks END flush at the tree's visible right edge; a row
879
+ whose body is already wider keeps one trailing space instead
880
+ (names are never truncated — horizontal scroll reveals its
881
+ badge). Before layout (width 0) fall back to one cell past the
882
+ widest badge-carrying body so alignment still holds headless.
883
+ """
884
+ measured: list[tuple[DiffFileEntry, int, int]] = []
885
+ for entry, indent in rows:
886
+ badges = _entry_source_suffix(entry)
887
+ if not badges:
888
+ continue
889
+ badge_width = 2 * len(badges) - 1 # markers joined by single spaces
890
+ measured.append((entry, indent + self._leaf_body(entry).cell_len, badge_width))
891
+ if not measured:
892
+ return
893
+ visible = tree.scrollable_content_region.width
894
+ end = visible if visible > 0 else max(w + 1 + bw for _, w, bw in measured)
895
+ for entry, width, badge_width in measured:
896
+ self._badge_pad[entry.path] = max(1, end - badge_width - width)
897
+
898
+ def _refresh_badge_alignment(self) -> None:
899
+ """Re-anchor the badge column after the pane's width changes."""
900
+ if not self._initialized:
901
+ return
902
+ # After-refresh callbacks can land after this pane left the DOM
903
+ # (the rollback modal swaps panes on turn change while an
904
+ # init/resize callback is still pending) — the trees are
905
+ # unmounted with it, so there is nothing to re-anchor.
906
+ try:
907
+ ws_tree = self.query_one(".workspace-tree", Tree)
908
+ except NoMatches:
909
+ return
910
+ self._assign_badge_pads(
911
+ [(e, (len(PurePath(e.rel_path).parts) - 1) * ws_tree.guide_depth) for e in self._cwd_entries],
912
+ ws_tree,
913
+ )
914
+ if self._ext_entries:
915
+ ext_tree = self.query_one(".external-tree", Tree)
916
+ self._assign_badge_pads([(e, ext_tree.guide_depth) for e in self._ext_entries], ext_tree)
917
+ self._relabel_all()
918
+
919
+ def on_resize(self) -> None:
920
+ # After-refresh so tree regions reflect the settled layout.
921
+ self.call_after_refresh(self._refresh_badge_alignment)
922
+
923
+ def _folder_label(self, name: str, state: str) -> Text:
924
+ """Build the Rich ``Text`` label for a directory node.
925
+
926
+ ``state`` is one of ``"on"`` / ``"off"`` / ``"mixed"``. The
927
+ checkbox glyph carries the same toggle meta as leaf checkboxes,
928
+ so clicking a folder's ``[~]`` cascades to all descendants (see
929
+ :meth:`toggle_check_node`).
930
+ """
931
+ if self._checkable:
932
+ return Text.assemble(_checkbox_text(state), " ", name)
933
+ return Text(name)
934
+
935
+ # ------------------------------------------------------------------
936
+ # Checkbox API (no-ops when ``checkable=False``)
937
+ # ------------------------------------------------------------------
938
+
939
+ def _iter_trees(self) -> list[Tree]:
940
+ """Return every Tree widget inside this pane (workspace + ext).
941
+
942
+ Queries on ``Tree`` (the base class) rather than ``CheckableTree``
943
+ so behaviour is unaffected if a subclass later swaps in a plain
944
+ Tree for some reason.
945
+ """
946
+ return list(self.query(Tree))
947
+
948
+ @on(CheckableTree.CheckToggled)
949
+ def _on_check_toggled(self, event: CheckableTree.CheckToggled) -> None:
950
+ """Mouse-click-to-toggle path — mirrors the ``x`` key action.
951
+
952
+ The tree subclass identifies which node the user clicked via
953
+ its checkbox meta; we just dispatch to the same cascade logic
954
+ the keyboard binding uses. Event is stopped so the message
955
+ doesn't bubble out past this pane.
956
+ """
957
+ event.stop()
958
+ self.toggle_check_node(event.node)
959
+
960
+ def _relabel_all(self) -> None:
961
+ """Recompute every leaf + folder label from ``self._unchecked``.
962
+
963
+ Folder tri-state is derived from descendant leaves: all checked
964
+ → ``[x]``, all unchecked → ``[ ]``, anything in between → ``[~]``.
965
+ """
966
+ for tree in self._iter_trees():
967
+ self._relabel_subtree(tree.root)
968
+
969
+ def _relabel_subtree(self, node: TreeNode) -> tuple[int, int]: # type: ignore[type-arg]
970
+ """Relabel ``node`` and its descendants.
971
+
972
+ Returns ``(total_leaves, unchecked_leaves)`` so parent folders
973
+ can compute their tri-state in one walk.
974
+ """
975
+ data = node.data
976
+ # Leaf node: a DiffFileEntry directly.
977
+ if isinstance(data, DiffFileEntry):
978
+ is_unchecked = data.path in self._unchecked
979
+ node.set_label(self._leaf_label(data))
980
+ return (1, 1 if is_unchecked else 0)
981
+
982
+ total = 0
983
+ unchecked = 0
984
+ for child in node.children:
985
+ ct, cu = self._relabel_subtree(child)
986
+ total += ct
987
+ unchecked += cu
988
+
989
+ # Update the folder label if this node represents a directory.
990
+ # The tree root has data=None (default) — skip it so the hidden
991
+ # root never gets a checkbox prefix.
992
+ if isinstance(data, _FolderNodeData) and total > 0:
993
+ if unchecked == 0:
994
+ state = "on"
995
+ elif unchecked == total:
996
+ state = "off"
997
+ else:
998
+ state = "mixed"
999
+ node.set_label(self._folder_label(data.name, state))
1000
+
1001
+ return (total, unchecked)
1002
+
1003
+ def toggle_check_node(self, node: TreeNode) -> None: # type: ignore[type-arg]
1004
+ """Toggle the check state for ``node``.
1005
+
1006
+ Leaves flip their own state. Folders follow the common
1007
+ tri-state convention: if every descendant is already checked,
1008
+ uncheck them all; otherwise check them all (completing from
1009
+ ``[ ]`` or ``[~]``). No-op when the pane isn't checkable.
1010
+
1011
+ Emits :class:`SelectionChanged` on every successful toggle so
1012
+ outside widgets (e.g. the rollback modal's "Revert N files"
1013
+ checkbox) can stay in sync.
1014
+ """
1015
+ if not self._checkable:
1016
+ return
1017
+
1018
+ data = node.data
1019
+ if isinstance(data, DiffFileEntry):
1020
+ if data.path in self._unchecked:
1021
+ self._unchecked.discard(data.path)
1022
+ else:
1023
+ self._unchecked.add(data.path)
1024
+ self._relabel_all()
1025
+ self._post_selection_changed()
1026
+ return
1027
+
1028
+ # Folder / root: gather descendant leaves.
1029
+ leaves = self._collect_descendant_leaves(node)
1030
+ if not leaves:
1031
+ return
1032
+ all_checked = all(p not in self._unchecked for p in leaves)
1033
+ if all_checked:
1034
+ self._unchecked.update(leaves)
1035
+ else:
1036
+ for p in leaves:
1037
+ self._unchecked.discard(p)
1038
+ self._relabel_all()
1039
+ self._post_selection_changed()
1040
+
1041
+ def _post_selection_changed(self) -> None:
1042
+ """Fire :class:`SelectionChanged` with the current totals."""
1043
+ self.post_message(
1044
+ self.SelectionChanged(
1045
+ selected_count=len(self.selected_paths()),
1046
+ total_count=len(self._entries),
1047
+ )
1048
+ )
1049
+
1050
+ def set_all_checked(self, checked: bool) -> None:
1051
+ """Bulk check or uncheck every leaf in the pane.
1052
+
1053
+ Used by the rollback modal's footer checkbox to mirror the
1054
+ "select all / select none" intent onto the tree. Emits a
1055
+ :class:`SelectionChanged` message once at the end so consumers
1056
+ don't see intermediate per-leaf transitions. No-op when the
1057
+ pane isn't checkable or the request matches current state.
1058
+ """
1059
+ if not self._checkable:
1060
+ return
1061
+ if checked:
1062
+ if not self._unchecked:
1063
+ return # already all-checked
1064
+ self._unchecked.clear()
1065
+ else:
1066
+ all_paths = {e.path for e in self._entries}
1067
+ if self._unchecked == all_paths:
1068
+ return # already all-unchecked
1069
+ self._unchecked = all_paths
1070
+ self._relabel_all()
1071
+ self._post_selection_changed()
1072
+
1073
+ def selection_counts(self) -> tuple[int, int]:
1074
+ """Return ``(selected_count, total_count)`` for checkable consumers."""
1075
+ return (len(self.selected_paths()), len(self._entries))
1076
+
1077
+ def _collect_descendant_leaves(self, node: TreeNode) -> list[str]: # type: ignore[type-arg]
1078
+ """Return every file path under ``node``."""
1079
+ result: list[str] = []
1080
+
1081
+ def walk(n: TreeNode) -> None: # type: ignore[type-arg]
1082
+ if isinstance(n.data, DiffFileEntry):
1083
+ result.append(n.data.path)
1084
+ return
1085
+ for child in n.children:
1086
+ walk(child)
1087
+
1088
+ walk(node)
1089
+ return result
1090
+
1091
+ def selected_paths(self) -> list[str]:
1092
+ """Paths the user wants to include (i.e. NOT in ``_unchecked``).
1093
+
1094
+ Returns all entry paths when the pane isn't checkable — the
1095
+ rollback modal is currently the only caller, but leaving this
1096
+ always-callable keeps the API simple for future reuse.
1097
+ """
1098
+ return [e.path for e in self._entries if e.path not in self._unchecked]
1099
+
1100
+ def set_change_list_visible(self, visible: bool) -> None:
1101
+ """Show or hide the left-side file change tree."""
1102
+ self.query_one(".diff-changelist", VerticalGroup).display = visible
1103
+
1104
+ async def on_tree_node_selected(self, event: Tree.NodeSelected[object]) -> None:
1105
+ # Folder nodes carry ``_FolderNodeData`` — show_file only makes
1106
+ # sense for real file leaves.
1107
+ if isinstance(event.node.data, DiffFileEntry):
1108
+ await self._show_file(event.node.data)
1109
+
1110
+ async def _show_file(self, entry: DiffFileEntry) -> None:
1111
+ """Display the diff for the selected file."""
1112
+ if entry.path == self._active_path:
1113
+ return
1114
+
1115
+ message = self.query_one(".diff-message", Static)
1116
+ content_box = self.query_one(".diff-content", VerticalGroup)
1117
+
1118
+ # Handle non-diffable cases
1119
+ if entry.content_omitted:
1120
+ # Content backup withheld by SnapshotPolicy — the row is
1121
+ # still actionable (e.g. revert deletes a skipped create)
1122
+ # but there is nothing truthful to render as a diff.
1123
+ self._hide_all_diffs()
1124
+ reason_definition = _SKIP_REASON_LABELS.get(entry.content_omitted)
1125
+ reason = (
1126
+ self._render_message(reason_definition.bind())
1127
+ if reason_definition is not None
1128
+ else entry.content_omitted
1129
+ )
1130
+ message.update(
1131
+ Text(self._render_message(_CONTENT_NOT_BACKED_UP.bind(reason=reason, path=DisplayPath(entry.rel_path))))
1132
+ )
1133
+ message.display = True
1134
+ self._update_content_border(content_box, entry)
1135
+ self._active_path = entry.path
1136
+ return
1137
+
1138
+ if entry.is_binary:
1139
+ self._hide_all_diffs()
1140
+ message.update(Text(self._render_message(_BINARY_FILE.bind(path=DisplayPath(entry.rel_path)))))
1141
+ message.display = True
1142
+ self._update_content_border(content_box, entry)
1143
+ self._active_path = entry.path
1144
+ return
1145
+
1146
+ if entry.operation == MutationOp.MOVE:
1147
+ self._hide_all_diffs()
1148
+ old_rel = entry.old_path or "?"
1149
+ message.update(
1150
+ Text(
1151
+ self._render_message(
1152
+ _FILE_MOVED.bind(
1153
+ old_path=DisplayPath(old_rel),
1154
+ new_path=DisplayPath(entry.rel_path),
1155
+ )
1156
+ )
1157
+ )
1158
+ )
1159
+ message.display = True
1160
+ self._update_content_border(content_box, entry)
1161
+ self._active_path = entry.path
1162
+ return
1163
+
1164
+ if _entry_is_eol_only_change(entry):
1165
+ self._hide_all_diffs()
1166
+ message.update(Text(self._render_message(_ONLY_LINE_ENDINGS_CHANGED.bind())))
1167
+ message.display = True
1168
+ self._update_content_border(content_box, entry, counts=(0, 0))
1169
+ self._active_path = entry.path
1170
+ return
1171
+
1172
+ if _entry_is_metadata_only_change(entry):
1173
+ self._hide_all_diffs()
1174
+ message.update(Text(self._render_message(_ONLY_BYTE_REPRESENTATION_CHANGED.bind())))
1175
+ message.display = True
1176
+ self._update_content_border(content_box, entry, counts=(0, 0))
1177
+ self._active_path = entry.path
1178
+ return
1179
+
1180
+ # Hide message, show diff
1181
+ message.display = False
1182
+
1183
+ # Hide current active diff
1184
+ if self._active_path and self._active_path in self._diff_cache:
1185
+ from chrys.app.tui.widgets.diff_view import DiffView
1186
+
1187
+ old_dv = self._diff_cache[self._active_path]
1188
+ if isinstance(old_dv, DiffView):
1189
+ old_dv.display = False
1190
+
1191
+ # Check cache or create new DiffView
1192
+ dv = None
1193
+ if entry.path in self._diff_cache:
1194
+ from chrys.app.tui.widgets.diff_view import DiffView
1195
+
1196
+ dv = self._diff_cache[entry.path]
1197
+ if isinstance(dv, DiffView):
1198
+ dv.display = True
1199
+ else:
1200
+ from chrys.app.tui.widgets.diff_view import DiffView
1201
+
1202
+ dv = DiffView(
1203
+ entry.path,
1204
+ entry.path,
1205
+ entry.before_text,
1206
+ entry.after_text,
1207
+ id=f"dv-{hash(entry.path) & 0xFFFFFFFF:08x}",
1208
+ )
1209
+ await dv.prepare()
1210
+ split = self._view_overrides.get(entry.path)
1211
+ if split is None:
1212
+ additions, removals = dv.counts
1213
+ split = additions > 0 and removals > 0
1214
+ dv.split = split
1215
+ await content_box.mount(dv, before=message)
1216
+ self._diff_cache[entry.path] = dv
1217
+
1218
+ self._update_content_border(content_box, entry, dv=dv)
1219
+ self._active_path = entry.path
1220
+
1221
+ def _update_content_border(
1222
+ self,
1223
+ content_box: VerticalGroup,
1224
+ entry: DiffFileEntry,
1225
+ dv: DiffView | None = None,
1226
+ counts: tuple[int, int] | None = None,
1227
+ ) -> None:
1228
+ """Update the diff-content border title with file path + stats and subtitle metadata."""
1229
+ from chrys.app.tui.widgets.diff_view import DiffView
1230
+
1231
+ title = entry.path
1232
+ if counts is not None:
1233
+ adds, rems = counts
1234
+ title = f"{entry.path} (+{adds}, -{rems})"
1235
+ elif isinstance(dv, DiffView):
1236
+ adds, rems = dv.counts
1237
+ title = f"{entry.path} (+{adds}, -{rems})"
1238
+ content_box.border_title = Text(title)
1239
+ content_box.border_subtitle = _entry_content_subtitle(entry, self._render_message)
1240
+
1241
+ def _hide_all_diffs(self) -> None:
1242
+ """Hide all cached DiffView widgets."""
1243
+ from chrys.app.tui.widgets.diff_view import DiffView
1244
+
1245
+ for dv in self._diff_cache.values():
1246
+ if isinstance(dv, DiffView):
1247
+ dv.display = False
1248
+
1249
+ def toggle_view(self) -> None:
1250
+ """Toggle split/unified for the active file."""
1251
+ from chrys.app.tui.widgets.diff_view import DiffView
1252
+
1253
+ if self._active_path and self._active_path in self._diff_cache:
1254
+ dv = self._diff_cache[self._active_path]
1255
+ if isinstance(dv, DiffView):
1256
+ new_split = not dv.split
1257
+ dv.split = new_split
1258
+ self._view_overrides[self._active_path] = new_split
1259
+
1260
+
1261
+ # ---------------------------------------------------------------------------
1262
+ # DiffScreen — full screen
1263
+ # ---------------------------------------------------------------------------
1264
+
1265
+
1266
+ class _DiffScreenContent(Widget):
1267
+ """Deferred tab host mounted only after the outer screen has painted."""
1268
+
1269
+ def __init__(
1270
+ self,
1271
+ periods_data: dict[int, list[DiffFileEntry]],
1272
+ all_entries: list[DiffFileEntry],
1273
+ *,
1274
+ initial_tab_id: str,
1275
+ scopes: dict[int, MutationScope],
1276
+ ) -> None:
1277
+ super().__init__(classes="diff-screen-content")
1278
+ self._periods_data = periods_data
1279
+ self._all_entries = all_entries
1280
+ self._initial_tab_id = initial_tab_id
1281
+ self._scopes = scopes
1282
+
1283
+ def compose(self) -> ComposeResult:
1284
+ render_message = functools.partial(render_str, widget_localizer(self))
1285
+ with TabbedContent(initial=self._initial_tab_id):
1286
+ for turn_id in sorted(self._periods_data):
1287
+ yield TabPane(
1288
+ Content.from_text(
1289
+ render_message(
1290
+ _RUN_TAB.bind(number=turn_id)
1291
+ if isinstance(self._scopes.get(turn_id), WorkflowRunScope)
1292
+ else _TURN_TAB.bind(turn_id=turn_id)
1293
+ ),
1294
+ markup=False,
1295
+ ),
1296
+ DiffPeriodPane(self._periods_data[turn_id], lazy=True),
1297
+ id=f"turn-{turn_id}",
1298
+ )
1299
+ # Keep the session-wide net changeset after the chronological
1300
+ # turn tabs. Hide it when it matches the sole visible turn.
1301
+ if _should_show_all_tab(self._all_entries, self._periods_data):
1302
+ yield TabPane(
1303
+ Content.from_text(render_message(_ALL_TAB.bind()), markup=False),
1304
+ DiffPeriodPane(self._all_entries, lazy=True),
1305
+ id="turn-all",
1306
+ )
1307
+
1308
+
1309
+ class DiffScreen(RightClickScreenCopyMixin, Screen):
1310
+ """Full-screen diff viewer with Chat Turn or Workflow Run tabs."""
1311
+
1312
+ CSS_PATH = "screen.tcss"
1313
+ _CONTENT_ACTIONS: ClassVar[frozenset[str]] = frozenset({"toggle_view", "toggle_change_list"})
1314
+
1315
+ BINDINGS: ClassVar[list] = [
1316
+ localized_binding("escape", "go_back", _BACK_BINDING, priority=True),
1317
+ localized_binding("space", "toggle_view", _TOGGLE_SPLIT_VIEW_BINDING, priority=True),
1318
+ localized_binding("ctrl+g", "toggle_change_list", _TOGGLE_CHANGE_LIST_BINDING, priority=True),
1319
+ ]
1320
+
1321
+ def __init__(
1322
+ self,
1323
+ periods_data: dict[int, list[DiffFileEntry]],
1324
+ cwd: str = "",
1325
+ subtitle_parts: tuple[str, ...] = (),
1326
+ session_id: str = "",
1327
+ all_entries: list[DiffFileEntry] | None = None,
1328
+ load_data: Callable[[], Awaitable[DiffLoadResult]] | None = None,
1329
+ locale_controller: LocaleController | None = None,
1330
+ scopes: dict[int, MutationScope] | None = None,
1331
+ ) -> None:
1332
+ super().__init__()
1333
+ self._periods_data = periods_data
1334
+ self._scopes = scopes or {}
1335
+ self._cwd = cwd
1336
+ self._subtitle_parts = subtitle_parts
1337
+ self._session_id = session_id
1338
+ self._all_entries = all_entries or []
1339
+ self._load_data = load_data
1340
+ self._locale_controller = locale_controller
1341
+ self._change_list_visible = True
1342
+ self._content_loading = True
1343
+ self._content_ready = False
1344
+ self._close_when_current = False
1345
+
1346
+ def _render_toast(self, reference: MessageRef) -> str:
1347
+ controller = self._locale_controller
1348
+ return format_message(reference) if controller is None else render_str(controller.localizer, reference)
1349
+
1350
+ def _initial_tab_id(self) -> str:
1351
+ """Return the tab that should be active when the screen opens."""
1352
+ if self._periods_data:
1353
+ return f"turn-{max(self._periods_data)}"
1354
+ if self._all_entries:
1355
+ return "turn-all"
1356
+ return ""
1357
+
1358
+ def compose(self) -> ComposeResult:
1359
+ from chrys.app.tui.widgets.chrome.app_header import AppHeader
1360
+
1361
+ header = AppHeader(show_approval_badge=False, locale_controller=self._locale_controller)
1362
+ yield header
1363
+ container = VerticalGroup(id="diff-container")
1364
+ from chrys.foundation.util.session_ids import session_short_id
1365
+
1366
+ title = (
1367
+ self._render_toast(_DIFF_VIEWER_SESSION_TITLE.bind(session_id=session_short_id(self._session_id)))
1368
+ if self._session_id
1369
+ else self._render_toast(_DIFF_VIEWER_TITLE.bind())
1370
+ )
1371
+ container.border_title = Text(title)
1372
+ container.border_subtitle = Text(self._cwd)
1373
+ with container, VerticalGroup(id="diff-loading-state"):
1374
+ yield ChrysLoadingIndicator(id="diff-loading")
1375
+ yield ChrysFooter(locale_controller=self._locale_controller)
1376
+
1377
+ def on_mount(self) -> None:
1378
+ # Propagate subtitle from the caller (platform info).
1379
+ if self._subtitle_parts:
1380
+ from chrys.app.tui.widgets.chrome.app_header import AppHeader
1381
+
1382
+ self.query_one(AppHeader).set_subtitle(*self._subtitle_parts)
1383
+ # Do not start loading until Textual has painted the header, outer
1384
+ # border, centered indicator, and footer at least once.
1385
+ self.call_after_refresh(self._start_content_loading)
1386
+
1387
+ def _start_content_loading(self) -> None:
1388
+ self.run_worker(self._load_and_mount_content(), exclusive=True, group="diff-screen-content")
1389
+
1390
+ async def _load_and_mount_content(self) -> None:
1391
+ """Load data off-loop, prepare the active pane, then reveal it atomically."""
1392
+ try:
1393
+ if self._load_data is not None:
1394
+ result = await self._load_data()
1395
+ self._periods_data = result.per_period_entries
1396
+ self._scopes = result.scopes
1397
+ self._all_entries = result.all_entries
1398
+ if not self._periods_data and not self._all_entries:
1399
+ self._content_loading = False
1400
+ self.notify(
1401
+ self._render_toast(_NO_FILE_CHANGES.bind()),
1402
+ title=self._render_toast(_DIFF_VIEWER_TITLE.bind()),
1403
+ severity="warning",
1404
+ timeout=3,
1405
+ markup=False,
1406
+ )
1407
+ self._request_close_when_current()
1408
+ return
1409
+
1410
+ content = _DiffScreenContent(
1411
+ self._periods_data,
1412
+ self._all_entries,
1413
+ initial_tab_id=self._initial_tab_id(),
1414
+ scopes=self._scopes,
1415
+ )
1416
+ content.display = False
1417
+ await self.query_one("#diff-container", VerticalGroup).mount(content)
1418
+
1419
+ # Keep the loading state visible until both the change tree and
1420
+ # first DiffView have completed their expensive preparation.
1421
+ await self._ensure_active_turn_pane_initialized()
1422
+ await remove_shielded(self.query_one("#diff-loading-state", VerticalGroup))
1423
+ content.display = True
1424
+ self._content_loading = False
1425
+ self._content_ready = True
1426
+ self.refresh_bindings()
1427
+ except Exception as exc:
1428
+ self._content_ready = False
1429
+ try:
1430
+ await self._show_load_error(exc)
1431
+ finally:
1432
+ self._content_loading = False
1433
+ with suppress(Exception):
1434
+ self.refresh_bindings()
1435
+
1436
+ async def _show_load_error(self, exc: Exception) -> None:
1437
+ """Show a recoverable error whether or not the loading shell remains."""
1438
+ if not self.is_attached:
1439
+ return
1440
+ with suppress(Exception):
1441
+ message = Static(
1442
+ Text(self._render_toast(_LOAD_ERROR.bind(error=str(exc)))),
1443
+ id="diff-load-error",
1444
+ )
1445
+ loading_states = list(self.query("#diff-loading-state"))
1446
+ if loading_states:
1447
+ loading_state = loading_states[0]
1448
+ await remove_children_shielded(loading_state)
1449
+ await loading_state.mount(message)
1450
+ return
1451
+ for content in self.query(".diff-screen-content"):
1452
+ content.display = False
1453
+ await self.query_one("#diff-container", VerticalGroup).mount(message)
1454
+
1455
+ def _request_close_when_current(self) -> None:
1456
+ """Close this screen now or after any transient screen above it."""
1457
+ self._close_when_current = True
1458
+ self._close_if_current()
1459
+
1460
+ def _close_if_current(self) -> None:
1461
+ """Pop only when this exact screen owns the top stack slot."""
1462
+ # ``Screen.is_current`` also includes transparent background
1463
+ # screens, so it is not strong enough to protect the top modal.
1464
+ if self.is_attached and self.app.screen is self:
1465
+ self._close_when_current = False
1466
+ self.app.pop_screen()
1467
+
1468
+ def on_screen_resume(self, _event: events.ScreenResume) -> None:
1469
+ """Honor a deferred close and recover stranded footer recomposition."""
1470
+ if self._close_when_current:
1471
+ self._close_if_current()
1472
+ return
1473
+ # A bindings signal that fires before this screen becomes active is
1474
+ # deliberately stranded by the Footer's active-screen guard; resuming
1475
+ # is the recovery point that replays it.
1476
+ self.query_one(ChrysFooter).sync_bindings()
1477
+
1478
+ @on(TabbedContent.TabActivated)
1479
+ async def _on_tab_activated(self, event: TabbedContent.TabActivated) -> None:
1480
+ if self._content_loading:
1481
+ return
1482
+ if event.tabbed_content is not self.query_one(TabbedContent):
1483
+ return
1484
+ pane = event.pane.query_one(DiffPeriodPane)
1485
+ pane.set_change_list_visible(self._change_list_visible)
1486
+ await pane.ensure_initialized()
1487
+
1488
+ async def _ensure_active_turn_pane_initialized(self) -> None:
1489
+ """Initialize the active turn pane without touching inactive tabs."""
1490
+ tc = self.query_one(TabbedContent)
1491
+ active = tc.active
1492
+ if not active:
1493
+ return
1494
+ active_pane = tc.query_one(f"#{active}", TabPane)
1495
+ turn_pane = active_pane.query_one(DiffPeriodPane)
1496
+ turn_pane.set_change_list_visible(self._change_list_visible)
1497
+ await turn_pane.ensure_initialized()
1498
+
1499
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
1500
+ """Hide and disable content actions until the prepared view is visible."""
1501
+ if action in self._CONTENT_ACTIONS and not self._content_ready:
1502
+ return False
1503
+ return super().check_action(action, parameters)
1504
+
1505
+ def action_go_back(self) -> None:
1506
+ if self._content_loading:
1507
+ # Unmount also cancels screen-owned workers, but cancel first so
1508
+ # no loader continuation can race with the asynchronous pop.
1509
+ self.workers.cancel_group(self, "diff-screen-content")
1510
+ self.app.pop_screen()
1511
+
1512
+ def action_toggle_view(self) -> None:
1513
+ """Toggle split/unified on the active tab's DiffPeriodPane."""
1514
+ if not self._content_ready:
1515
+ return
1516
+ try:
1517
+ tc = self.query_one(TabbedContent)
1518
+ except NoMatches:
1519
+ return
1520
+ active_pane = tc.query_one(f"#{tc.active}", TabPane)
1521
+ if active_pane is not None:
1522
+ turn_pane = active_pane.query_one(DiffPeriodPane)
1523
+ if turn_pane is not None:
1524
+ turn_pane.toggle_view()
1525
+
1526
+ def action_toggle_change_list(self) -> None:
1527
+ """Toggle the file change list tree for every diff tab."""
1528
+ if not self._content_ready:
1529
+ return
1530
+ self._change_list_visible = not self._change_list_visible
1531
+ for turn_pane in self.query(DiffPeriodPane):
1532
+ turn_pane.set_change_list_visible(self._change_list_visible)