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,2142 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """AgentsConfigScreen — unified agent configuration modal with sidebar and tabbed config."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ import copy
9
+ import logging
10
+ import re
11
+ import uuid
12
+ from collections.abc import Callable, Collection
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, ClassVar
17
+
18
+ from rich.text import Text
19
+ from textual import on
20
+ from textual.containers import Horizontal, Vertical
21
+ from textual.content import Content
22
+ from textual.css.query import NoMatches
23
+ from textual.widgets import Button, OptionList, Select, Static, TabbedContent, TabPane, TextArea
24
+ from textual.widgets.option_list import Option, OptionDoesNotExist
25
+
26
+ from chrys.app.tui.binding_display import CANCEL_BINDING, localized_binding
27
+ from chrys.app.tui.i18n import render_str, widget_locale_controller, widget_localizer
28
+ from chrys.app.tui.screens.agents.agent_draft_store import AgentDraftStore, mcp_draft_errors_by_server
29
+ from chrys.app.tui.screens.agents.panels.config_card import CardListChanged
30
+ from chrys.app.tui.screens.agents.validation_messages import FIX_VALIDATION_ERRORS
31
+ from chrys.app.tui.screens.dialogs.base import BaseDialog
32
+ from chrys.app.tui.widgets import Checkbox
33
+ from chrys.app.tui.widgets import EnhancedInput as Input
34
+ from chrys.foundation.i18n import DisplayBlock, MessageDef, MessageRef, msg
35
+
36
+ if TYPE_CHECKING:
37
+ from textual.app import ComposeResult
38
+ from textual.widget import Widget
39
+
40
+ from chrys.service.profiles.agents.registry import AgentProfileRegistry, AgentProfileRegistrySnapshot
41
+ from chrys.service.profiles.agents.schema import AgentProfile
42
+ from chrys.service.profiles.models.registry import ModelProfileRegistry
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ _READ_ONLY_NOTICE = msg(
47
+ "tui.agent_config.read_only_notice",
48
+ fallback="• Agent is running. This page is read-only.",
49
+ )
50
+
51
+ _CONFIGURATION_TITLE = msg("tui.agent_config.title.configuration", fallback="Agent Configuration")
52
+ _TAB_BASIC = msg("tui.agent_config.tab.basic", fallback="Basic")
53
+ _TAB_INSTRUCTIONS = msg("tui.agent_config.tab.instructions", fallback="Instructions")
54
+ _TAB_TOOLS = msg("tui.agent_config.tab.tools", fallback="Tools")
55
+ _TAB_SUB_AGENTS = msg("tui.agent_config.tab.sub_agents", fallback="Sub-Agents")
56
+ _TAB_SKILLS = msg("tui.agent_config.tab.skills", fallback="Skills")
57
+ _TAB_MCP = msg("tui.agent_config.tab.mcp", fallback="MCP")
58
+ _TAB_MEMORY = msg("tui.agent_config.tab.memory", fallback="Memory")
59
+ _TAB_COMPACTION = msg("tui.agent_config.tab.compaction", fallback="Compaction")
60
+ _TAB_ACP_SETTINGS = msg("tui.agent_config.tab.acp_settings", fallback="ACP Settings")
61
+ _TAB_ACP_SHORT = msg("tui.agent_config.tab.acp_short", fallback="ACP")
62
+ _NEW = msg("tui.agent_config.button.new", fallback="New")
63
+ _CLONE = msg("tui.agent_config.button.clone", fallback="Clone")
64
+ _DELETE = msg("tui.agent_config.button.delete", fallback="Delete")
65
+ _RESET = msg("tui.agent_config.button.reset", fallback="Reset")
66
+ _SAVE = msg("tui.agent_config.button.save", fallback="Save")
67
+ _CLOSE = msg("tui.agent_config.button.close", fallback="Close")
68
+ _MODIFIED_LABEL = msg("tui.agent_config.profile.modified", fallback="{name} (modified)")
69
+ _RESET_AGENT_TITLE = msg("tui.agent_config.confirm.reset.title", fallback="Reset Agent")
70
+ _DELETE_AGENT_TITLE = msg("tui.agent_config.confirm.delete.title", fallback="Delete Agent")
71
+ _RESET_AGENT_MESSAGE = msg(
72
+ "tui.agent_config.confirm.reset.message",
73
+ fallback=(
74
+ 'Reset built-in agent\n"{display}" to system defaults?\n\n'
75
+ "Skills, MCP, and Memory settings will be preserved. All other changes will be discarded."
76
+ ),
77
+ multiline=True,
78
+ )
79
+ _DELETE_AGENT_MESSAGE = msg(
80
+ "tui.agent_config.confirm.delete.message",
81
+ fallback='Delete agent profile\n"{display}"?',
82
+ multiline=True,
83
+ )
84
+ _TAB_ERROR = msg("tui.agent_config.panel_error.detail", fallback="{tab}: {error}", multiline=True)
85
+ _PANELS_STILL_MOUNTING = msg(
86
+ "tui.agent_config.panel_error.mounting",
87
+ fallback="Agent config panels are still mounting:\n{details}",
88
+ multiline=True,
89
+ )
90
+ _PANELS_READ_ERROR = msg(
91
+ "tui.agent_config.panel_error.read",
92
+ fallback="Could not read tab configs:\n{details}",
93
+ multiline=True,
94
+ )
95
+
96
+ _AGENT_TITLE = msg("tui.agent_config.title.agent", fallback="Agent")
97
+ _AGENT_SETTINGS_TITLE = msg("tui.agent_config.title.settings", fallback="Agent Settings")
98
+ _VALIDATION_ERROR_TITLE = msg("tui.agent_config.title.validation_error", fallback="Validation Error")
99
+ _SAVE_ERROR_TITLE = msg("tui.agent_config.title.save_error", fallback="Save Error")
100
+ _LAST_AGENT = msg(
101
+ "tui.agent_config.last_agent",
102
+ fallback="Cannot remove the last agent profile — at least one must remain.",
103
+ )
104
+ _LAST_MAIN_AGENT = msg(
105
+ "tui.agent_config.last_main_agent",
106
+ fallback="Cannot remove the last main agent profile — add another first.",
107
+ )
108
+ _AGENT_STILL_REFERENCED = msg(
109
+ "tui.agent_config.still_referenced",
110
+ fallback=(
111
+ "Cannot remove '{name}' — still used as a sub-agent by: {preview}{suffix}. Unlink it from those agents first."
112
+ ),
113
+ )
114
+ _AGENT_MORE_SUFFIX = msg("tui.agent_config.more_suffix", fallback=" (+{extra_count} more)")
115
+ _AGENT_REMOVED = msg("tui.agent_config.removed", fallback="Agent removed")
116
+ _AGENT_DELETED = msg("tui.agent_config.deleted", fallback="Agent deleted")
117
+ _AGENT_RESET_PENDING = msg(
118
+ "tui.agent_config.reset_pending",
119
+ fallback="Agent reset prepared — save to apply",
120
+ )
121
+ _AGENT_RESET_COMPLETE = msg(
122
+ "tui.agent_config.reset_complete",
123
+ fallback="Agent reset to system defaults",
124
+ )
125
+ _AGENT_ALREADY_DEFAULT = msg(
126
+ "tui.agent_config.already_default",
127
+ fallback="Agent already uses the system defaults",
128
+ )
129
+ _AGENT_PROFILE_SAVED = msg("tui.agent_config.saved", fallback="Agent profile saved")
130
+
131
+
132
+ class _DismissIntent(StrEnum):
133
+ """Modal result to return when the user closes the config screen."""
134
+
135
+ NONE = ""
136
+ UPDATED = "updated"
137
+ SWITCHED = "switched"
138
+
139
+
140
+ @dataclass
141
+ class _AgentDraft:
142
+ """Modal-local staged state for one agent profile."""
143
+
144
+ key: str
145
+ original_name: str | None
146
+ profile: AgentProfile
147
+ original_profile: AgentProfile | None
148
+ is_builtin: bool
149
+ dirty: bool = False
150
+ reset_to_builtin: bool = False
151
+
152
+
153
+ @dataclass
154
+ class _AgentDraftSnapshot:
155
+ """Restorable staged draft state for failed save attempts."""
156
+
157
+ profile: AgentProfile
158
+ dirty: bool
159
+ reset_to_builtin: bool
160
+
161
+
162
+ @dataclass
163
+ class _AgentSaveSnapshot:
164
+ """State needed to roll back a multi-profile save."""
165
+
166
+ files: dict[Path, bytes | None]
167
+ registry: AgentProfileRegistrySnapshot
168
+ current_profile: str
169
+ active_profile_name: str
170
+
171
+
172
+ class _AgentConfigPanelsNotReady(RuntimeError):
173
+ """Raised when a panel exists but its child widgets have not mounted yet."""
174
+
175
+
176
+ class AgentsConfigScreen(BaseDialog[str]):
177
+ """Unified agent configuration modal.
178
+
179
+ Left sidebar lists all agent profiles; selecting one loads its
180
+ configuration in the tabbed right panel.
181
+
182
+ Tabs: Basic, Instructions, Tools, Sub-Agents, Skills, MCP, Compaction.
183
+
184
+ Dismiss result:
185
+ - ``""`` — user cancelled, nothing to reload.
186
+ - ``"updated"`` — user saved; caller should reload the registry.
187
+ - ``"switched"`` — active agent profile changed (e.g. deleted), caller
188
+ should reload and switch the engine to the new active profile.
189
+ """
190
+
191
+ CSS_PATH = "config.tcss"
192
+ _HYDRATION_RETRY_SECONDS: ClassVar[float] = 0.02
193
+ _HYDRATION_MAX_ATTEMPTS: ClassVar[int] = 25
194
+ _TAB_IDS: ClassVar[tuple[str, ...]] = (
195
+ "basic",
196
+ "instructions",
197
+ "tools",
198
+ "sub-agents",
199
+ "skills",
200
+ "mcp",
201
+ "memory",
202
+ "compaction",
203
+ "acp",
204
+ )
205
+
206
+ BINDINGS: ClassVar[list] = [
207
+ localized_binding("escape", "cancel", CANCEL_BINDING, show=False, priority=True),
208
+ ]
209
+
210
+ def __init__(
211
+ self,
212
+ registry: AgentProfileRegistry,
213
+ current_profile: str,
214
+ initial_profile: str = "",
215
+ initial_tab: str = "basic",
216
+ *,
217
+ active_profile_name: str = "",
218
+ model_registry: ModelProfileRegistry | None = None,
219
+ active_model_profile_id: str = "",
220
+ workspace_cwd: str | None = None,
221
+ workspace_roots: list[str] | None = None,
222
+ on_saved: Callable[[str | None, str | None], None] | None = None,
223
+ read_only: bool = False,
224
+ ) -> None:
225
+ self._registry = registry
226
+ self._model_registry = model_registry
227
+ self._active_model_profile_id = active_model_profile_id
228
+ self._workspace_cwd = workspace_cwd
229
+ self._workspace_roots = [root for root in workspace_roots or [] if root]
230
+ if not self._workspace_roots and workspace_cwd:
231
+ self._workspace_roots = [workspace_cwd]
232
+ self._read_only = read_only
233
+ self._current_profile = current_profile
234
+ self._initial_profile = initial_profile or current_profile
235
+ self._initial_tab = initial_tab
236
+ self._selected_profile_name: str = ""
237
+ self._selected_draft_key: str = ""
238
+ self._drafts: dict[str, _AgentDraft] = {}
239
+ self._mounted_tabs: set[str] = set()
240
+ # Per draft key: its MCP server cards' folds when it was last shown. The panel is
241
+ # rebuilt from the draft, which carries no UI state, each time the agent loads.
242
+ self._mcp_card_folds: dict[str, list[bool]] = {}
243
+ self._hydrating: bool = False
244
+ self._hydration_preserve_dirty: bool = False
245
+ self._hydrating_generation: int = 0
246
+ self._pending_agent_type_change: tuple[str, str] | None = None
247
+ self._dismiss_intent = _DismissIntent.NONE
248
+ # Resolve the session-active profile's *registry name* (stable
249
+ # identifier — ``display_name`` is freely editable and ``name``
250
+ # is editable for user profiles). Prefer the caller-supplied
251
+ # ``active_profile_name`` when provided (unambiguous); otherwise
252
+ # fall back to matching ``current_profile`` against ``.name``
253
+ # first and ``display_name`` second so a profile whose display
254
+ # label collides with another profile's registry name still
255
+ # resolves correctly.
256
+ self._original_active_profile_name: str = ""
257
+ if active_profile_name and registry.get(active_profile_name) is not None:
258
+ self._original_active_profile_name = active_profile_name
259
+ else:
260
+ profiles = registry.list_profiles(include_sub_agent_only=True)
261
+ for p in profiles:
262
+ if p.name == current_profile:
263
+ self._original_active_profile_name = p.name
264
+ break
265
+ else:
266
+ for p in profiles:
267
+ if (p.display_name or p.name) == current_profile:
268
+ self._original_active_profile_name = p.name
269
+ break
270
+ self._active_profile_name = self._original_active_profile_name
271
+ # Sync callback fired after each successful Save (no dismiss).
272
+ # Args: (new_display_label, new_registry_name).
273
+ # ``new_display_label`` is non-None when the active profile's
274
+ # display label changed (caller refreshes main-screen widgets).
275
+ # ``new_registry_name`` is non-None when the active profile's
276
+ # registry name changed (caller queues an engine switch for
277
+ # modal Close).
278
+ self._on_saved = on_saved
279
+ super().__init__()
280
+
281
+ # ── Compose ──────────────────────────────────────────────────────
282
+
283
+ def compose(self) -> ComposeResult:
284
+ localizer = widget_localizer(self)
285
+ with Vertical(id="ac-container") as container:
286
+ container.border_title = Text(render_str(localizer, _CONFIGURATION_TITLE.bind()))
287
+
288
+ with Horizontal(id="ac-main"):
289
+ # Left sidebar
290
+ with Vertical(id="ac-sidebar"):
291
+ yield OptionList(id="ac-list")
292
+
293
+ # Right panel
294
+ with Vertical(id="ac-right"), TabbedContent(initial=self._initial_tab, id="ac-tabs"):
295
+ with TabPane(render_str(localizer, _TAB_BASIC.bind()), id="basic"):
296
+ pass
297
+ with TabPane(render_str(localizer, _TAB_INSTRUCTIONS.bind()), id="instructions"):
298
+ pass
299
+ with TabPane(render_str(localizer, _TAB_TOOLS.bind()), id="tools"):
300
+ pass
301
+ with TabPane(render_str(localizer, _TAB_SUB_AGENTS.bind()), id="sub-agents"):
302
+ pass
303
+ with TabPane(render_str(localizer, _TAB_SKILLS.bind()), id="skills"):
304
+ pass
305
+ with TabPane(render_str(localizer, _TAB_MCP.bind()), id="mcp"):
306
+ pass
307
+ with TabPane(render_str(localizer, _TAB_MEMORY.bind()), id="memory"):
308
+ pass
309
+ with TabPane(render_str(localizer, _TAB_COMPACTION.bind()), id="compaction"):
310
+ pass
311
+ with TabPane(render_str(localizer, _TAB_ACP_SETTINGS.bind()), id="acp"):
312
+ pass
313
+
314
+ # Footer buttons
315
+ with Vertical(id="ac-footer") as footer:
316
+ if self._read_only:
317
+ footer.add_class("-read-only")
318
+ with Horizontal(id="ac-buttons"):
319
+ yield Button(render_str(localizer, _NEW.bind()), variant="primary", id="ac-new", flat=True)
320
+ yield Button(render_str(localizer, _CLONE.bind()), variant="primary", id="ac-clone", flat=True)
321
+ yield Button(render_str(localizer, _RESET.bind()), variant="warning", id="ac-reset", flat=True)
322
+ yield Button(render_str(localizer, _DELETE.bind()), variant="error", id="ac-delete", flat=True)
323
+ yield Static(id="ac-buttons-spacer")
324
+ save_button = Button(
325
+ render_str(localizer, _SAVE.bind()), variant="primary", id="ac-save", flat=True
326
+ )
327
+ save_button.disabled = True
328
+ yield save_button
329
+ yield Button(render_str(localizer, _CLOSE.bind()), variant="warning", id="ac-cancel", flat=True)
330
+ notice = Static(Text(render_str(localizer, _READ_ONLY_NOTICE.bind())), id="ac-read-only-notice")
331
+ notice.display = self._read_only
332
+ yield notice
333
+
334
+ # ── Mount ────────────────────────────────────────────────────────
335
+
336
+ def on_mount(self) -> None:
337
+ self._initialize_drafts()
338
+ self._populate_sidebar()
339
+ self._select_initial_profile()
340
+ self._update_save_button_state()
341
+ self._apply_read_only_state()
342
+
343
+ def on_unmount(self) -> None:
344
+ self._hydrating_generation += 1
345
+ self._hydrating = False
346
+ self._hydration_preserve_dirty = False
347
+ self._pending_agent_type_change = None
348
+
349
+ @staticmethod
350
+ def _existing_draft_key(profile_name: str) -> str:
351
+ return f"profile:{profile_name}"
352
+
353
+ @staticmethod
354
+ def _new_draft_key() -> str:
355
+ return f"draft:{uuid.uuid4().hex}"
356
+
357
+ def _initialize_drafts(self) -> None:
358
+ """Seed modal-local drafts from the current registry state."""
359
+ self._drafts.clear()
360
+ self._mcp_card_folds.clear()
361
+ profiles = self._registry.list_profiles(include_sub_agent_only=True)
362
+ for profile in profiles:
363
+ key = self._existing_draft_key(profile.name)
364
+ original = copy.deepcopy(profile)
365
+ self._canonicalize_profile_for_ui(original)
366
+ draft_profile = copy.deepcopy(profile)
367
+ self._canonicalize_profile_for_ui(draft_profile)
368
+ self._drafts[key] = _AgentDraft(
369
+ key=key,
370
+ original_name=profile.name,
371
+ profile=draft_profile,
372
+ original_profile=original,
373
+ is_builtin=self._registry.is_builtin(profile.name),
374
+ )
375
+
376
+ @staticmethod
377
+ def _canonicalize_profile_for_ui(profile) -> None:
378
+ """Match harmless normalizations done by tab panels (ordering)."""
379
+ from chrys.app.tui.screens.agents.panels.tools import _ALL_CATEGORIES
380
+
381
+ enabled = set(profile.tools.builtins)
382
+ known = {cat for cat, _label, _desc, _enabled in _ALL_CATEGORIES}
383
+ ordered = [cat for cat, _label, _desc, _enabled in _ALL_CATEGORIES if cat in enabled]
384
+ ordered.extend(cat for cat in profile.tools.builtins if cat not in known)
385
+ profile.tools.builtins = ordered
386
+ profile.skills.script_extensions = sorted(profile.skills.script_extensions)
387
+ profile.name = profile.name.strip()
388
+ profile.display_name = profile.display_name.strip()
389
+ profile.description = profile.description.strip()
390
+ for ref in profile.sub_agents.agents:
391
+ ref.profile = ref.profile.strip()
392
+ ref.tool_name = ref.tool_name.strip()
393
+ ref.tool_description = ref.tool_description.strip()
394
+ profile.memory.files = [path.strip() for path in profile.memory.files if path.strip()]
395
+ profile.memory.folders = [path.strip() for path in profile.memory.folders if path.strip()]
396
+ profile.skills.paths = [path.strip() for path in profile.skills.paths if path.strip()]
397
+ for server in profile.tools.mcp:
398
+ server.name = server.name.strip()
399
+ server.description = server.description.strip()
400
+ server.tool_name_prefix = server.tool_name_prefix.strip()
401
+ server.allowed_tools = (
402
+ [tool.strip() for tool in server.allowed_tools if tool.strip()]
403
+ if server.allowed_tools is not None
404
+ else None
405
+ )
406
+ server.always_load = [tool.strip() for tool in server.always_load if tool.strip()]
407
+ if not server.use_progressive_disclosure or server.allowed_tools == []:
408
+ server.use_progressive_disclosure = False
409
+ server.always_load = []
410
+ # Mirror AcpConfigPanel.get_config exactly (command/cwd/mode/model
411
+ # stripped, mapping keys stripped with empty keys dropped, argument
412
+ # values untouched) so hydration settles and dirty stays honest.
413
+ if profile.acp is not None:
414
+ acp = profile.acp
415
+ acp.command = acp.command.strip()
416
+ acp.cwd = acp.cwd.strip()
417
+ acp.session_mode = acp.session_mode.strip()
418
+ acp.model_id = acp.model_id.strip()
419
+ acp.env = {key.strip(): value for key, value in acp.env.items() if key.strip()}
420
+ acp.config_options = {key.strip(): value for key, value in acp.config_options.items() if key.strip()}
421
+
422
+ def _visible_drafts(self) -> list[_AgentDraft]:
423
+ return list(self._drafts.values())
424
+
425
+ def _find_visible_draft_key(self, profile_name: str) -> str | None:
426
+ for draft in self._visible_drafts():
427
+ if draft.profile.name == profile_name:
428
+ return draft.key
429
+ return None
430
+
431
+ def _populate_sidebar(self) -> None:
432
+ ol = self.query_one("#ac-list", OptionList)
433
+ ol.clear_options()
434
+ drafts = self._visible_drafts()
435
+ for i, draft in enumerate(drafts):
436
+ if i > 0:
437
+ ol.add_option(None)
438
+ profile = draft.profile
439
+ label = profile.display_name or profile.name
440
+ if draft.dirty:
441
+ label = self._render_toast(_MODIFIED_LABEL.bind(name=label))
442
+ is_current = draft.original_name == self._active_profile_name
443
+ prefix = "◦ " if is_current else ""
444
+ content = Content.assemble(
445
+ (f"{prefix}{label}", "bold"),
446
+ (f"\n{profile.description}", "dim") if profile.description else "",
447
+ )
448
+ ol.add_option(Option(content, id=draft.key))
449
+
450
+ def _select_initial_profile(self) -> None:
451
+ """Highlight and load the initial profile."""
452
+ drafts = self._visible_drafts()
453
+ if not drafts:
454
+ return
455
+
456
+ # Resolve initial profile name from display name first. If caller
457
+ # supplied a display label that happens to match another profile's
458
+ # registry name, the display-label match is the intended target.
459
+ target = ""
460
+ for draft in drafts:
461
+ profile = draft.profile
462
+ if (profile.display_name or profile.name) == self._initial_profile:
463
+ target = draft.key
464
+ break
465
+ if not target:
466
+ visible_by_name = {draft.profile.name: draft.key for draft in drafts}
467
+ target = visible_by_name.get(self._initial_profile, drafts[0].key)
468
+
469
+ ol = self.query_one("#ac-list", OptionList)
470
+ with contextlib.suppress(OptionDoesNotExist):
471
+ ol.highlighted = ol.get_option_index(target)
472
+
473
+ self._load_profile(target)
474
+
475
+ # ── Profile loading ──────────────────────────────────────────────
476
+
477
+ def _load_profile(self, draft_key: str, *, mcp_card_folds_carried: bool = False) -> None:
478
+ """Clear tab panes and mount fresh config panels for the given draft.
479
+
480
+ ``mcp_card_folds_carried``: the caller has already set every draft's
481
+ MCP card folds (Save, which rebuilt the drafts), so leave them as set.
482
+ """
483
+ draft = self._drafts.get(draft_key)
484
+ if draft is None:
485
+ return
486
+ pending_agent_type = self._pending_agent_type_change
487
+ if pending_agent_type is not None and pending_agent_type[0] != draft_key:
488
+ self._pending_agent_type_change = None
489
+ if not mcp_card_folds_carried:
490
+ # Also on a reload in place: Reset keeps the MCP servers, and a panel
491
+ # ignores folds that no longer match its server count.
492
+ self._remember_mcp_card_folds()
493
+ profile = draft.profile
494
+ self._selected_draft_key = draft_key
495
+ self._selected_profile_name = profile.name
496
+ is_builtin = draft.is_builtin
497
+
498
+ self.query_one("#ac-reset", Button).display = is_builtin
499
+ self.query_one("#ac-delete", Button).display = not is_builtin
500
+
501
+ # Clear all panes, then mount only the active tab. Additional tabs are
502
+ # mounted on first activation so the modal can paint without building
503
+ # every editor panel up front.
504
+ self._hydrating = True
505
+ self._hydration_preserve_dirty = False
506
+ self._mounted_tabs.clear()
507
+ self._clear_tab_panes()
508
+ self._configure_tabs_for_profile(profile)
509
+ active_tab = self._active_tab_id()
510
+ if (profile.acp is not None and active_tab not in {"basic", "acp"}) or (
511
+ profile.acp is None and active_tab == "acp"
512
+ ):
513
+ active_tab = "basic"
514
+ self.query_one("#ac-tabs", TabbedContent).active = active_tab
515
+ self._ensure_tab_mounted(active_tab, profile, is_builtin)
516
+ self._schedule_hydration_finish(preserve_dirty=False)
517
+ self._apply_read_only_state()
518
+
519
+ def _active_tab_id(self) -> str:
520
+ with contextlib.suppress(NoMatches):
521
+ active = self.query_one("#ac-tabs", TabbedContent).active
522
+ if active in self._TAB_IDS:
523
+ return active
524
+ return self._initial_tab if self._initial_tab in self._TAB_IDS else "basic"
525
+
526
+ def _clear_tab_panes(self) -> None:
527
+ for tab_id in self._TAB_IDS:
528
+ self.query_one(f"#{tab_id}", TabPane).remove_children()
529
+
530
+ def _configure_tabs_for_profile(self, profile: AgentProfile) -> None:
531
+ tabs = self.query_one("#ac-tabs", TabbedContent)
532
+ builtin_tabs = ("instructions", "tools", "sub-agents", "skills", "mcp", "memory", "compaction")
533
+ if profile.acp is not None:
534
+ for tab_id in builtin_tabs:
535
+ tabs.hide_tab(tab_id)
536
+ tabs.show_tab("acp")
537
+ else:
538
+ for tab_id in builtin_tabs:
539
+ tabs.show_tab(tab_id)
540
+ tabs.hide_tab("acp")
541
+
542
+ def _ensure_tab_mounted(self, tab_id: str, profile, is_builtin: bool) -> None:
543
+ if tab_id in self._mounted_tabs:
544
+ return
545
+ if tab_id == "basic":
546
+ self._mount_basic_tab(profile, is_builtin)
547
+ elif tab_id == "instructions":
548
+ self._mount_instructions_tab(profile)
549
+ elif tab_id == "tools":
550
+ self._mount_tools_tab(profile)
551
+ elif tab_id == "sub-agents":
552
+ self._mount_subagents_tab(profile)
553
+ elif tab_id == "skills":
554
+ self._mount_skills_tab(profile)
555
+ elif tab_id == "mcp":
556
+ self._mount_mcp_tab(profile)
557
+ elif tab_id == "memory":
558
+ self._mount_memory_tab(profile)
559
+ elif tab_id == "compaction":
560
+ self._mount_compaction_tab(profile)
561
+ elif tab_id == "acp" and profile.acp is not None:
562
+ self._mount_acp_tab(profile)
563
+ else:
564
+ return
565
+ self._mounted_tabs.add(tab_id)
566
+ self._apply_read_only_state()
567
+ self.call_after_refresh(self._apply_read_only_state)
568
+
569
+ def _schedule_hydration_finish(self, *, preserve_dirty: bool) -> None:
570
+ self._hydrating = True
571
+ self._hydration_preserve_dirty = self._hydration_preserve_dirty or preserve_dirty
572
+ self._hydrating_generation += 1
573
+ generation = self._hydrating_generation
574
+ self.set_timer(self._HYDRATION_RETRY_SECONDS, lambda: self._finish_hydrating(generation, 0))
575
+
576
+ def _dirty_after_hydration(self, draft: _AgentDraft) -> bool:
577
+ if self._read_only:
578
+ return False
579
+ return True if self._hydration_preserve_dirty else self._is_draft_dirty(draft)
580
+
581
+ def _finish_hydrating(self, generation: int, attempts: int) -> None:
582
+ if generation != self._hydrating_generation or not self.is_attached:
583
+ return
584
+ pending_agent_type = self._pending_agent_type_change
585
+ if pending_agent_type is not None and pending_agent_type[0] == self._selected_draft_key:
586
+ self.call_after_refresh(lambda: self._complete_hydration(generation))
587
+ return
588
+ draft = self._drafts.get(self._selected_draft_key)
589
+ if draft is not None:
590
+ next_attempt = attempts + 1
591
+ try:
592
+ mounted_profile = self._build_profile_from_mounted_panels(draft)
593
+ if mounted_profile != draft.profile:
594
+ if next_attempt < self._HYDRATION_MAX_ATTEMPTS:
595
+ self.set_timer(
596
+ self._HYDRATION_RETRY_SECONDS,
597
+ lambda: self._finish_hydrating(generation, next_attempt),
598
+ )
599
+ return
600
+ logger.warning(
601
+ "Agent config hydration did not settle for profile %r after %s attempts",
602
+ draft.profile.name,
603
+ next_attempt,
604
+ )
605
+ else:
606
+ self._set_draft_dirty(draft, self._dirty_after_hydration(draft))
607
+ except _AgentConfigPanelsNotReady as exc:
608
+ if next_attempt < self._HYDRATION_MAX_ATTEMPTS:
609
+ self.set_timer(
610
+ self._HYDRATION_RETRY_SECONDS,
611
+ lambda: self._finish_hydrating(generation, next_attempt),
612
+ )
613
+ return
614
+ logger.warning(
615
+ "Agent config hydration timed out for profile %r after %s attempts: %s",
616
+ draft.profile.name,
617
+ next_attempt,
618
+ exc,
619
+ )
620
+ except Exception:
621
+ if next_attempt < self._HYDRATION_MAX_ATTEMPTS:
622
+ self.set_timer(
623
+ self._HYDRATION_RETRY_SECONDS,
624
+ lambda: self._finish_hydrating(generation, next_attempt),
625
+ )
626
+ return
627
+ logger.warning(
628
+ "Agent config hydration failed for profile %r after %s attempts",
629
+ draft.profile.name,
630
+ next_attempt,
631
+ exc_info=True,
632
+ )
633
+ # Clear the hydration guard only after the next refresh. Field-change
634
+ # events (e.g. a sub-agent Select.Changed) queued while the panels were
635
+ # mounting are still in the message queue; draining them before the guard
636
+ # drops keeps _mark_selected_dirty gated for that settle traffic. Clearing
637
+ # synchronously let those queued events land after the guard cleared and
638
+ # force a spurious, sticky dirty that wrongly enabled Save -- an
639
+ # intermittent CI failure on the dirty-tracking tests.
640
+ self.call_after_refresh(lambda: self._complete_hydration(generation))
641
+
642
+ def _complete_hydration(self, generation: int) -> None:
643
+ if generation != self._hydrating_generation or not self.is_attached:
644
+ return
645
+ preserve_dirty = self._hydration_preserve_dirty
646
+ self._hydrating = False
647
+ self._hydration_preserve_dirty = False
648
+ draft = self._drafts.get(self._selected_draft_key)
649
+ if draft is not None and not self._read_only:
650
+ self._set_draft_dirty(draft, True if preserve_dirty else self._is_draft_dirty(draft))
651
+ self._update_save_button_state()
652
+ self._apply_read_only_state()
653
+ pending_agent_type = self._pending_agent_type_change
654
+ self._pending_agent_type_change = None
655
+ if pending_agent_type is not None and pending_agent_type[0] == self._selected_draft_key:
656
+ self._apply_agent_type_change(*pending_agent_type)
657
+
658
+ def _mount_basic_tab(self, profile, is_builtin: bool) -> None:
659
+ from chrys.app.tui.screens.agents.panels.basic import BasicConfigPanel
660
+
661
+ pane = self.query_one("#basic", TabPane)
662
+ pane.remove_children()
663
+ pane.mount(
664
+ BasicConfigPanel(
665
+ profile,
666
+ is_builtin=is_builtin,
667
+ model_registry=self._model_registry,
668
+ active_profile_id=self._active_model_profile_id,
669
+ read_only=self._read_only,
670
+ )
671
+ )
672
+
673
+ def _mount_instructions_tab(self, profile) -> None:
674
+ from chrys.app.tui.screens.agents.panels.instructions import InstructionsConfigPanel
675
+
676
+ pane = self.query_one("#instructions", TabPane)
677
+ pane.remove_children()
678
+ pane.mount(InstructionsConfigPanel(profile.instructions))
679
+
680
+ def _mount_tools_tab(self, profile) -> None:
681
+ from chrys.app.tui.screens.agents.panels.tools import ToolsConfigPanel
682
+
683
+ pane = self.query_one("#tools", TabPane)
684
+ pane.remove_children()
685
+ pane.mount(ToolsConfigPanel(profile.tools, read_only=self._read_only))
686
+
687
+ def _mount_subagents_tab(self, profile) -> None:
688
+ from chrys.app.tui.screens.agents.panels.subagents import SubAgentsConfigPanel
689
+
690
+ pane = self.query_one("#sub-agents", TabPane)
691
+ pane.remove_children()
692
+ pane.mount(
693
+ SubAgentsConfigPanel(
694
+ profile.sub_agents,
695
+ registry=self._registry,
696
+ current_profile_name=profile.name,
697
+ available_profiles=self._sub_agent_available_profiles(),
698
+ read_only=self._read_only,
699
+ )
700
+ )
701
+
702
+ def _sub_agent_available_profiles(self) -> list[AgentProfile]:
703
+ profiles: list[AgentProfile] = []
704
+ seen_names: set[str] = set()
705
+
706
+ def add(profile: AgentProfile) -> None:
707
+ if not profile.name or profile.name in seen_names:
708
+ return
709
+ seen_names.add(profile.name)
710
+ profiles.append(profile)
711
+
712
+ visible_drafts = self._visible_drafts()
713
+ represented_originals = {draft.original_name for draft in visible_drafts if draft.original_name is not None}
714
+ for draft in visible_drafts:
715
+ if draft.key == self._selected_draft_key:
716
+ continue
717
+ if draft.original_profile is not None and draft.original_name != draft.profile.name:
718
+ add(draft.original_profile)
719
+ for draft in visible_drafts:
720
+ add(draft.profile)
721
+ for profile in self._registry.list_profiles(include_sub_agent_only=True):
722
+ if profile.name in represented_originals:
723
+ continue
724
+ add(profile)
725
+ return profiles
726
+
727
+ def _mount_skills_tab(self, profile) -> None:
728
+ from chrys.app.tui.screens.agents.panels.skills import SkillsConfigPanel
729
+
730
+ pane = self.query_one("#skills", TabPane)
731
+ pane.remove_children()
732
+ pane.mount(SkillsConfigPanel(profile.skills, workspace_cwd=self._workspace_cwd, read_only=self._read_only))
733
+
734
+ def _remember_mcp_card_folds(self) -> None:
735
+ """Keep the selected draft's MCP card folds for the next time it loads."""
736
+ from chrys.app.tui.screens.agents.panels.mcp import MCPConfigPanel
737
+
738
+ key = self._selected_draft_key
739
+ if key not in self._drafts or "mcp" not in self._mounted_tabs:
740
+ return
741
+ with contextlib.suppress(NoMatches):
742
+ self._mcp_card_folds[key] = self._live_panel(MCPConfigPanel).card_folds()
743
+
744
+ def _mount_mcp_tab(self, profile) -> None:
745
+ from chrys.app.tui.screens.agents.panels.mcp import MCPConfigPanel
746
+
747
+ pane = self.query_one("#mcp", TabPane)
748
+ pane.remove_children()
749
+ pane.mount(
750
+ MCPConfigPanel(
751
+ profile.tools.mcp,
752
+ card_folds=self._mcp_card_folds.get(self._selected_draft_key),
753
+ workspace_cwd=self._workspace_cwd,
754
+ read_only=self._read_only,
755
+ additional_reserved_tool_names=self._selected_sub_agent_tool_names,
756
+ )
757
+ )
758
+
759
+ def _selected_sub_agent_tool_names(self) -> set[str]:
760
+ """Return live sub-agent names reserved by the selected draft."""
761
+ draft = self._drafts.get(self._selected_draft_key)
762
+ if draft is None:
763
+ return set()
764
+ return {tool_name for ref in draft.profile.sub_agents.agents if (tool_name := ref.tool_name or ref.profile)}
765
+
766
+ def _mount_memory_tab(self, profile) -> None:
767
+ from chrys.app.tui.screens.agents.panels.memory import MemoryConfigPanel
768
+
769
+ pane = self.query_one("#memory", TabPane)
770
+ pane.remove_children()
771
+ pane.mount(MemoryConfigPanel(profile.memory, workspace_cwd=self._workspace_cwd, read_only=self._read_only))
772
+
773
+ def _mount_compaction_tab(self, profile) -> None:
774
+ from chrys.app.tui.screens.agents.panels.compaction import CompactionConfigPanel
775
+
776
+ pane = self.query_one("#compaction", TabPane)
777
+ pane.remove_children()
778
+ pane.mount(CompactionConfigPanel(profile.compaction))
779
+
780
+ def _mount_acp_tab(self, profile) -> None:
781
+ from chrys.app.tui.screens.agents.panels.acp import AcpConfigPanel
782
+
783
+ if profile.acp is None:
784
+ return
785
+ pane = self.query_one("#acp", TabPane)
786
+ pane.remove_children()
787
+ pane.mount(
788
+ AcpConfigPanel(
789
+ profile.acp,
790
+ workspace_cwd=self._workspace_cwd,
791
+ workspace_roots=self._workspace_roots,
792
+ read_only=self._read_only,
793
+ )
794
+ )
795
+
796
+ # ── Sidebar selection ────────────────────────────────────────────
797
+
798
+ @on(OptionList.OptionSelected, "#ac-list")
799
+ def _on_profile_selected(self, event: OptionList.OptionSelected) -> None:
800
+ target_key = event.option.id
801
+ if target_key and target_key != self._selected_draft_key:
802
+ if not self._read_only:
803
+ try:
804
+ self._sync_selected_draft_from_panels()
805
+ except Exception as exc:
806
+ self.notify(
807
+ str(exc),
808
+ title=self._render_toast(_AGENT_TITLE.bind()),
809
+ severity="error",
810
+ timeout=5,
811
+ markup=False,
812
+ )
813
+ self._restore_sidebar_highlight()
814
+ return
815
+ self._refresh_sidebar_preserving_selection()
816
+ ol = self.query_one("#ac-list", OptionList)
817
+ with contextlib.suppress(OptionDoesNotExist):
818
+ ol.highlighted = ol.get_option_index(target_key)
819
+ self._load_profile(target_key)
820
+
821
+ def _restore_sidebar_highlight(self) -> None:
822
+ if not self._selected_draft_key:
823
+ return
824
+ ol = self.query_one("#ac-list", OptionList)
825
+ with contextlib.suppress(OptionDoesNotExist):
826
+ ol.highlighted = ol.get_option_index(self._selected_draft_key)
827
+
828
+ def _refresh_sidebar_preserving_selection(self) -> None:
829
+ self._populate_sidebar()
830
+ self._restore_sidebar_highlight()
831
+
832
+ @on(TabbedContent.TabActivated, "#ac-tabs")
833
+ def _on_tab_activated(self, event: TabbedContent.TabActivated) -> None:
834
+ tab_id = event.pane.id or ""
835
+ if tab_id not in self._TAB_IDS or not self._selected_draft_key:
836
+ return
837
+ draft = self._drafts.get(self._selected_draft_key)
838
+ if draft is None:
839
+ return
840
+ tab_mounted = tab_id in self._mounted_tabs
841
+ if not self._hydrating and not self._read_only:
842
+ self._mark_selected_dirty(force=True)
843
+ if not tab_mounted:
844
+ preserve_dirty = draft.dirty
845
+ self._hydrating = True
846
+ self._ensure_tab_mounted(tab_id, draft.profile, draft.is_builtin)
847
+ self._schedule_hydration_finish(preserve_dirty=preserve_dirty)
848
+ self._apply_read_only_state()
849
+
850
+ def _update_save_button_state(self) -> None:
851
+ if not self.is_attached:
852
+ return
853
+ try:
854
+ save = self.query_one("#ac-save", Button)
855
+ except NoMatches:
856
+ return
857
+ if self._read_only:
858
+ save.disabled = True
859
+ save.display = False
860
+ return
861
+ dirty = any(draft.dirty for draft in self._drafts.values())
862
+ save.disabled = not dirty
863
+
864
+ @staticmethod
865
+ def _is_read_only_hidden_button(button_id: str) -> bool:
866
+ if button_id in {
867
+ "ac-new",
868
+ "ac-clone",
869
+ "ac-reset",
870
+ "ac-delete",
871
+ "ac-save",
872
+ "sa-add-btn",
873
+ "mem-add-file",
874
+ "mem-add-folder",
875
+ "sk-add-btn",
876
+ "mcp-add-btn",
877
+ "acp-add-arg",
878
+ "acp-add-env",
879
+ "acp-add-option",
880
+ "acp-test",
881
+ }:
882
+ return True
883
+ return button_id.startswith(
884
+ (
885
+ "mcp-hadd-",
886
+ "mcp-eadd-",
887
+ "mcp-hdel-",
888
+ "mcp-edel-",
889
+ "mcp-delete-btn-",
890
+ "mcp-test-btn-",
891
+ "acp-",
892
+ "sa-delete-btn-",
893
+ "mem-delete-btn-",
894
+ "mem-file-browse-",
895
+ "mem-folder-browse-",
896
+ "sk-browse-",
897
+ "sk-delete-btn-",
898
+ )
899
+ )
900
+
901
+ def _apply_read_only_state(self) -> None:
902
+ """Make mounted profile controls view-only while preserving navigation."""
903
+ if not self._read_only or not self.is_attached:
904
+ return
905
+ try:
906
+ self.query_one("#ac-footer", Vertical).add_class("-read-only")
907
+ self.query_one("#ac-read-only-notice", Static).display = True
908
+ except NoMatches:
909
+ return
910
+ for input_widget in self.query(Input):
911
+ input_widget.disabled = True
912
+ for select in self.query(Select):
913
+ select.disabled = True
914
+ for checkbox in self.query(Checkbox):
915
+ checkbox.disabled = True
916
+ for text_area in self.query(TextArea):
917
+ text_area.read_only = True
918
+ for button in self.query(Button):
919
+ button_id = button.id or ""
920
+ if button_id == "ac-cancel":
921
+ button.disabled = False
922
+ button.display = True
923
+ continue
924
+ button.disabled = True
925
+ if self._is_read_only_hidden_button(button_id):
926
+ button.display = False
927
+
928
+ def _set_dismiss_intent(self, intent: _DismissIntent) -> None:
929
+ if self._dismiss_intent == _DismissIntent.SWITCHED or intent == _DismissIntent.NONE:
930
+ return
931
+ self._dismiss_intent = intent
932
+
933
+ def _is_draft_dirty(self, draft: _AgentDraft) -> bool:
934
+ if draft.original_profile is None:
935
+ return True
936
+ return draft.reset_to_builtin or draft.profile != draft.original_profile
937
+
938
+ def _set_draft_dirty(self, draft: _AgentDraft, dirty: bool) -> None:
939
+ if draft.dirty != dirty:
940
+ draft.dirty = dirty
941
+ self._refresh_sidebar_preserving_selection()
942
+ self._update_save_button_state()
943
+
944
+ def _snapshot_drafts(self) -> dict[str, _AgentDraftSnapshot]:
945
+ return {
946
+ key: _AgentDraftSnapshot(
947
+ profile=copy.deepcopy(draft.profile),
948
+ dirty=draft.dirty,
949
+ reset_to_builtin=draft.reset_to_builtin,
950
+ )
951
+ for key, draft in self._drafts.items()
952
+ }
953
+
954
+ def _restore_drafts(self, snapshot: dict[str, _AgentDraftSnapshot]) -> None:
955
+ for key, state in snapshot.items():
956
+ draft = self._drafts.get(key)
957
+ if draft is None:
958
+ continue
959
+ draft.profile = copy.deepcopy(state.profile)
960
+ draft.dirty = state.dirty
961
+ draft.reset_to_builtin = state.reset_to_builtin
962
+ selected = self._drafts.get(self._selected_draft_key)
963
+ if selected is not None:
964
+ self._selected_profile_name = selected.profile.name
965
+ self._refresh_sidebar_preserving_selection()
966
+ self._update_save_button_state()
967
+
968
+ def _mounted_panel_validation_errors(self) -> list[str]:
969
+ return self._validate_all()
970
+
971
+ def _mounted_panels_have_validation_errors(self) -> bool:
972
+ return bool(self._mounted_panel_validation_errors())
973
+
974
+ def _mark_selected_dirty(self, *, force: bool = False, optimistic: bool = False) -> None:
975
+ if self._read_only:
976
+ return
977
+ if self._hydrating or not self._selected_draft_key:
978
+ return
979
+ draft = self._drafts.get(self._selected_draft_key)
980
+ if draft is None:
981
+ return
982
+ if optimistic:
983
+ self._set_draft_dirty(draft, True)
984
+ return
985
+ try:
986
+ draft.profile = self._build_profile_from_mounted_panels(draft)
987
+ except _AgentConfigPanelsNotReady:
988
+ # Panels are still mounting/settling — a transient read must not flip
989
+ # the dirty flag. A field-change event (e.g. a sub-agent Select.Changed)
990
+ # firing in the post-hydration settle window, after _hydrating clears
991
+ # but before the panels are queryable, would otherwise force a spurious
992
+ # dirty that sticks and wrongly enables Save. Leave dirty untouched; the
993
+ # next settled comparison (or a real edit) sets it correctly.
994
+ self._update_save_button_state()
995
+ return
996
+ except Exception:
997
+ if force:
998
+ self._set_draft_dirty(draft, True)
999
+ else:
1000
+ logger.debug("Could not compare mounted agent config to draft", exc_info=True)
1001
+ self._update_save_button_state()
1002
+ return
1003
+ dirty = self._is_draft_dirty(draft)
1004
+ if not dirty and (force or draft.dirty):
1005
+ dirty = self._mounted_panels_have_validation_errors()
1006
+ self._set_draft_dirty(draft, dirty)
1007
+
1008
+ @on(Input.Changed)
1009
+ @on(Checkbox.Changed)
1010
+ @on(Select.Changed)
1011
+ @on(TextArea.Changed)
1012
+ def _on_config_field_changed(self, event: object) -> None:
1013
+ if isinstance(event, Select.Changed) and event.select.id == "bc-agent-type":
1014
+ # The discriminator handler must sanitize hidden sections before
1015
+ # any generic harvest can install ``acp`` and make the switch
1016
+ # appear already complete.
1017
+ return
1018
+ self._mark_selected_dirty(force=True)
1019
+
1020
+ @on(Select.Changed, "#bc-agent-type")
1021
+ def _on_agent_type_changed(self, event: Select.Changed) -> None:
1022
+ """Apply the destructive ACP discriminator switch after its inline warning."""
1023
+ if self._read_only or event.value not in {"builtin", "acp"}:
1024
+ return
1025
+ draft = self._drafts.get(self._selected_draft_key)
1026
+ if draft is None or draft.is_builtin:
1027
+ return
1028
+ agent_type = str(event.value)
1029
+ wants_acp = agent_type == "acp"
1030
+ if wants_acp == (draft.profile.acp is not None):
1031
+ pending_agent_type = self._pending_agent_type_change
1032
+ if self._hydrating and pending_agent_type is not None and pending_agent_type[0] == draft.key:
1033
+ self._pending_agent_type_change = None
1034
+ return
1035
+ if self._hydrating:
1036
+ self._pending_agent_type_change = (draft.key, agent_type)
1037
+ return
1038
+ self._apply_agent_type_change(draft.key, agent_type)
1039
+
1040
+ def _apply_agent_type_change(self, draft_key: str, agent_type: str) -> None:
1041
+ if self._read_only or draft_key != self._selected_draft_key:
1042
+ return
1043
+ draft = self._drafts.get(draft_key)
1044
+ if draft is None or draft.is_builtin:
1045
+ return
1046
+ wants_acp = agent_type == "acp"
1047
+ if wants_acp == (draft.profile.acp is not None):
1048
+ return
1049
+ from chrys.app.tui.screens.agents.panels.basic import BasicConfigPanel
1050
+
1051
+ basic = self._live_panel(BasicConfigPanel).get_config()
1052
+ self._apply_basic_to_profile(draft.profile, basic, is_builtin=False)
1053
+ if wants_acp:
1054
+ self._sanitize_profile_for_acp(draft.profile)
1055
+ else:
1056
+ draft.profile.acp = None
1057
+ self._canonicalize_profile_for_ui(draft.profile)
1058
+ self._set_draft_dirty(draft, True)
1059
+ self._selected_profile_name = draft.profile.name
1060
+ self._load_profile(draft.key)
1061
+
1062
+ @staticmethod
1063
+ def _sanitize_profile_for_acp(profile: AgentProfile) -> None:
1064
+ from chrys.service.profiles.agents.schema import (
1065
+ AcpAgentConfig,
1066
+ ApprovalConfig,
1067
+ CompactionConfig,
1068
+ MemoryConfig,
1069
+ ModelConfig,
1070
+ SkillsConfig,
1071
+ SubAgentsConfig,
1072
+ ToolsConfig,
1073
+ )
1074
+
1075
+ profile.acp = profile.acp or AcpAgentConfig()
1076
+ profile.sub_agent_only = True
1077
+ profile.instructions = ""
1078
+ profile.tools = ToolsConfig()
1079
+ profile.skills = SkillsConfig()
1080
+ profile.sub_agents = SubAgentsConfig()
1081
+ profile.memory = MemoryConfig()
1082
+ profile.compaction = CompactionConfig()
1083
+ profile.approval = ApprovalConfig()
1084
+ profile.model = ModelConfig()
1085
+
1086
+ @on(Button.Pressed)
1087
+ def _on_config_structure_button_pressed(self, event: Button.Pressed) -> None:
1088
+ if self._read_only:
1089
+ return
1090
+ button_id = event.button.id or ""
1091
+ if not self._is_config_mutation_button(button_id):
1092
+ return
1093
+ self._mark_structure_changed()
1094
+
1095
+ @on(CardListChanged)
1096
+ def _on_card_list_changed(self, _event: CardListChanged) -> None:
1097
+ if self._read_only:
1098
+ return
1099
+ self._mark_structure_changed()
1100
+
1101
+ def _mark_structure_changed(self) -> None:
1102
+ # The panel rebuilds its rows asynchronously: mark dirty now, then re-read once it has settled.
1103
+ self._mark_selected_dirty(force=True, optimistic=True)
1104
+ self.call_after_refresh(lambda: self._mark_selected_dirty(force=True))
1105
+
1106
+ @staticmethod
1107
+ def _is_config_mutation_button(button_id: str) -> bool:
1108
+ # Not "mcp-delete-btn-": that press opens a confirmation, and the panel
1109
+ # posts CardListChanged once a confirmed delete lands.
1110
+ if button_id in {
1111
+ "sa-add-btn",
1112
+ "mem-add-file",
1113
+ "mem-add-folder",
1114
+ "sk-add-btn",
1115
+ "mcp-add-btn",
1116
+ "acp-add-arg",
1117
+ "acp-add-env",
1118
+ "acp-add-option",
1119
+ }:
1120
+ return True
1121
+ return button_id.startswith(
1122
+ (
1123
+ "sk-ext-",
1124
+ "sk-browse-",
1125
+ "mcp-hadd-",
1126
+ "mcp-eadd-",
1127
+ "mcp-hdel-",
1128
+ "mcp-edel-",
1129
+ "acp-row-delete-",
1130
+ "sa-delete-btn-",
1131
+ "mem-delete-btn-",
1132
+ "sk-delete-btn-",
1133
+ )
1134
+ )
1135
+
1136
+ # ── Buttons ──────────────────────────────────────────────────────
1137
+
1138
+ @on(Button.Pressed, "#ac-new")
1139
+ def _on_new(self, _event: Button.Pressed) -> None:
1140
+ """Stage a new blank profile and select it."""
1141
+ if self._read_only:
1142
+ return
1143
+ from chrys.service.profiles.agents.schema import AgentProfile
1144
+
1145
+ try:
1146
+ self._sync_selected_draft_from_panels()
1147
+ except Exception as exc:
1148
+ self.notify(
1149
+ str(exc),
1150
+ title=self._render_toast(_AGENT_TITLE.bind()),
1151
+ severity="error",
1152
+ timeout=5,
1153
+ markup=False,
1154
+ )
1155
+ return
1156
+
1157
+ # Generate a unique name
1158
+ base = "new-agent"
1159
+ name = base
1160
+ counter = 1
1161
+ while self._draft_name_exists(name):
1162
+ counter += 1
1163
+ name = f"{base}-{counter}"
1164
+
1165
+ profile = AgentProfile(
1166
+ name=name,
1167
+ id=self._next_profile_id(),
1168
+ display_name=name,
1169
+ description="A custom agent profile",
1170
+ instructions="You are a helpful AI assistant. Follow the user's instructions carefully and provide clear, concise responses.",
1171
+ )
1172
+ self._canonicalize_profile_for_ui(profile)
1173
+ key = self._new_draft_key()
1174
+ self._drafts[key] = _AgentDraft(
1175
+ key=key,
1176
+ original_name=None,
1177
+ profile=profile,
1178
+ original_profile=None,
1179
+ is_builtin=False,
1180
+ dirty=True,
1181
+ )
1182
+
1183
+ self._refresh_sidebar_preserving_selection()
1184
+ ol = self.query_one("#ac-list", OptionList)
1185
+ with contextlib.suppress(OptionDoesNotExist):
1186
+ ol.highlighted = ol.get_option_index(key)
1187
+ self._load_profile(key)
1188
+ self._update_save_button_state()
1189
+
1190
+ def _draft_name_exists(self, name: str) -> bool:
1191
+ lowered = name.casefold()
1192
+ return lowered in {reserved.casefold() for reserved in self._reserved_profile_names()}
1193
+
1194
+ def _reserved_profile_names(self) -> set[str]:
1195
+ """Names unavailable for generated profiles across drafts and registry entries."""
1196
+ names = set(self._registry.list_names())
1197
+ names.update(draft.profile.name for draft in self._drafts.values() if draft.profile.name)
1198
+ return names
1199
+
1200
+ def _next_profile_id(self) -> str:
1201
+ """Generate a fresh agent profile id."""
1202
+ existing_ids = {
1203
+ profile.id for profile in self._registry.list_profiles(include_sub_agent_only=True) if profile.id
1204
+ }
1205
+ existing_ids.update(draft.profile.id for draft in self._drafts.values() if draft.profile.id)
1206
+ while True:
1207
+ profile_id = uuid.uuid4().hex[:12]
1208
+ if profile_id not in existing_ids:
1209
+ return profile_id
1210
+
1211
+ @staticmethod
1212
+ def _clone_name_parts(name: str) -> tuple[str, int] | None:
1213
+ """Return ``(root, suffix_index)`` for generated clone names."""
1214
+ match = re.fullmatch(r"(.+?)-copy(?:-(\d+))?", name, flags=re.IGNORECASE)
1215
+ if match is None:
1216
+ return None
1217
+ suffix = match.group(2)
1218
+ return match.group(1), int(suffix) if suffix is not None else 1
1219
+
1220
+ def _clone_name_indices(self, root_name: str) -> list[int]:
1221
+ """Return existing generated clone suffixes for the given root."""
1222
+ indices: list[int] = []
1223
+ for name in self._reserved_profile_names():
1224
+ parts = self._clone_name_parts(name)
1225
+ if parts is not None and parts[0].casefold() == root_name.casefold():
1226
+ indices.append(parts[1])
1227
+ return indices
1228
+
1229
+ @staticmethod
1230
+ def _clone_display_root(source_display_name: str) -> str:
1231
+ """Return the original display root for a generated clone label."""
1232
+ match = re.fullmatch(r"(.+?) copy(?: \d+)?", source_display_name, flags=re.IGNORECASE)
1233
+ return source_display_name if match is None else match.group(1)
1234
+
1235
+ def _next_clone_names(self, source_name: str, source_display_name: str) -> tuple[str, str]:
1236
+ """Generate unique registry and display names for a cloned agent profile."""
1237
+ existing_names = {name.casefold() for name in self._reserved_profile_names()}
1238
+ source_parts = self._clone_name_parts(source_name)
1239
+ if source_parts is None:
1240
+ root_name = source_name
1241
+ counter = 1
1242
+ else:
1243
+ root_name, source_index = source_parts
1244
+ source_display_name = self._clone_display_root(source_display_name)
1245
+ counter = max([source_index, *self._clone_name_indices(root_name)]) + 1
1246
+ base_name = f"{root_name}-copy"
1247
+ base_display_name = f"{source_display_name} Copy"
1248
+ name = base_name if counter == 1 else f"{base_name}-{counter}"
1249
+ display_name = base_display_name if counter == 1 else f"{base_display_name} {counter}"
1250
+ while name.casefold() in existing_names:
1251
+ counter += 1
1252
+ name = f"{base_name}-{counter}"
1253
+ display_name = f"{base_display_name} {counter}"
1254
+ return name, display_name
1255
+
1256
+ @on(Button.Pressed, "#ac-clone")
1257
+ def _on_clone(self, _event: Button.Pressed) -> None:
1258
+ """Stage a copy of the selected profile and select it."""
1259
+ if self._read_only:
1260
+ return
1261
+ try:
1262
+ self._sync_selected_draft_from_panels()
1263
+ except Exception as exc:
1264
+ self.notify(
1265
+ str(exc),
1266
+ title=self._render_toast(_AGENT_TITLE.bind()),
1267
+ severity="error",
1268
+ timeout=5,
1269
+ markup=False,
1270
+ )
1271
+ return
1272
+
1273
+ source_draft = self._drafts.get(self._selected_draft_key)
1274
+ if source_draft is None:
1275
+ return
1276
+
1277
+ source = source_draft.profile
1278
+ duplicate = copy.deepcopy(source)
1279
+ duplicate.name, duplicate.display_name = self._next_clone_names(source.name, source.display_name or source.name)
1280
+ duplicate.id = self._next_profile_id()
1281
+ self._canonicalize_profile_for_ui(duplicate)
1282
+ key = self._new_draft_key()
1283
+ self._drafts[key] = _AgentDraft(
1284
+ key=key,
1285
+ original_name=None,
1286
+ profile=duplicate,
1287
+ original_profile=None,
1288
+ is_builtin=False,
1289
+ dirty=True,
1290
+ )
1291
+ # The copy has the same MCP servers: open their cards as the original shows them.
1292
+ self._remember_mcp_card_folds()
1293
+ if (folds := self._mcp_card_folds.get(source_draft.key)) is not None:
1294
+ self._mcp_card_folds[key] = list(folds)
1295
+
1296
+ self._refresh_sidebar_preserving_selection()
1297
+ ol = self.query_one("#ac-list", OptionList)
1298
+ with contextlib.suppress(OptionDoesNotExist):
1299
+ ol.highlighted = ol.get_option_index(key)
1300
+ self._load_profile(key)
1301
+ self._update_save_button_state()
1302
+
1303
+ @on(Button.Pressed, "#ac-reset")
1304
+ def _on_reset(self, _event: Button.Pressed) -> None:
1305
+ """Stage a built-in profile reset after confirmation."""
1306
+ if self._read_only:
1307
+ return
1308
+ try:
1309
+ self._sync_selected_draft_from_panels()
1310
+ except Exception as exc:
1311
+ self.notify(
1312
+ str(exc),
1313
+ title=self._render_toast(_AGENT_TITLE.bind()),
1314
+ severity="error",
1315
+ timeout=5,
1316
+ markup=False,
1317
+ )
1318
+ return
1319
+ draft_key = self._selected_draft_key
1320
+ draft = self._drafts.get(draft_key)
1321
+ if draft is None or not draft.is_builtin or draft.original_name is None:
1322
+ return
1323
+ display = draft.profile.display_name or draft.profile.name
1324
+ from chrys.app.tui.screens.dialogs.confirm import ConfirmDialog
1325
+
1326
+ dialog = ConfirmDialog(
1327
+ title=_RESET_AGENT_TITLE.bind(),
1328
+ message=_RESET_AGENT_MESSAGE.bind(display=display),
1329
+ confirm_label=_RESET.bind(),
1330
+ confirm_variant="primary",
1331
+ locale_controller=widget_locale_controller(self),
1332
+ )
1333
+ self.app.push_screen(
1334
+ dialog,
1335
+ callback=lambda confirmed: self._do_reset(draft_key) if confirmed else None,
1336
+ )
1337
+
1338
+ def _do_reset(self, draft_key: str) -> None:
1339
+ """Replace one built-in draft with its reset candidate."""
1340
+ if self._read_only:
1341
+ return
1342
+ draft = self._drafts.get(draft_key)
1343
+ if draft is None or not draft.is_builtin or draft.original_name is None:
1344
+ return
1345
+ reset_profile = self._registry.build_builtin_reset(
1346
+ draft.original_name,
1347
+ preserve_from=draft.profile,
1348
+ )
1349
+ self._canonicalize_profile_for_ui(reset_profile)
1350
+ draft_changed = reset_profile != draft.profile
1351
+ shadow_exists = self._profile_yaml_path(draft.original_name).is_file()
1352
+ original = draft.original_profile
1353
+ reset_matches_template = self._profile_matches_builtin_template(reset_profile, draft.original_name)
1354
+ needs_save = reset_profile != original or (shadow_exists and reset_matches_template)
1355
+ draft.profile = reset_profile
1356
+ draft.reset_to_builtin = needs_save
1357
+ self._selected_profile_name = reset_profile.name
1358
+ self._set_draft_dirty(draft, needs_save)
1359
+ self._load_profile(draft_key)
1360
+ message = (
1361
+ _AGENT_RESET_PENDING if needs_save else _AGENT_RESET_COMPLETE if draft_changed else _AGENT_ALREADY_DEFAULT
1362
+ )
1363
+ self.notify(
1364
+ self._render_toast(message.bind()),
1365
+ title=self._render_toast(_AGENT_SETTINGS_TITLE.bind()),
1366
+ severity="information",
1367
+ timeout=3,
1368
+ markup=False,
1369
+ )
1370
+
1371
+ @on(Button.Pressed, "#ac-delete")
1372
+ def _on_delete(self, _event: Button.Pressed) -> None:
1373
+ """Delete the selected custom profile after confirmation."""
1374
+ if self._read_only:
1375
+ return
1376
+ draft_key = self._selected_draft_key
1377
+ draft = self._drafts.get(draft_key)
1378
+ if draft is None or draft.is_builtin:
1379
+ return
1380
+ name = draft.profile.name
1381
+ # Block deleting the last profile overall. An
1382
+ # empty registry would leave the main screen's input-bar tag
1383
+ # bound to a missing profile (no label, no description) and
1384
+ # give the engine nothing to fall back to on ``SettingsReload``.
1385
+ remaining_visible = self._remaining_visible_after_delete(draft)
1386
+ if not remaining_visible:
1387
+ self.notify(
1388
+ self._render_toast(_LAST_AGENT.bind()),
1389
+ title=self._render_toast(_AGENT_TITLE.bind()),
1390
+ severity="warning",
1391
+ timeout=5,
1392
+ markup=False,
1393
+ )
1394
+ return
1395
+ # Block deleting the last main-eligible profile (i.e.
1396
+ # one that can serve as the active main agent — not
1397
+ # ``sub_agent_only``). The engine must always be bound to a
1398
+ # real main-agent profile; sub-agent-only profiles cannot fill
1399
+ # that slot. Sub-agent-only profiles themselves may be freely
1400
+ # deleted (having zero sub-agents is a valid state) — as long
1401
+ # as the general guard above still permits it.
1402
+ target = draft.original_profile or draft.profile
1403
+ if not target.sub_agent_only:
1404
+ # ``list_profiles()`` excludes ``sub_agent_only`` by default.
1405
+ remaining_main = [p for p in remaining_visible if not p.sub_agent_only]
1406
+ if not remaining_main:
1407
+ self.notify(
1408
+ self._render_toast(_LAST_MAIN_AGENT.bind()),
1409
+ title=self._render_toast(_AGENT_TITLE.bind()),
1410
+ severity="warning",
1411
+ timeout=5,
1412
+ markup=False,
1413
+ )
1414
+ return
1415
+
1416
+ # Block deleting an agent that is still referenced as a
1417
+ # sub-agent by other profiles. Without this guard the registry
1418
+ # would silently strip the reference from every parent profile
1419
+ # and re-save them — a dangerous action to take without the
1420
+ # user's explicit consent. Make the user unlink manually first.
1421
+ target_names = {target.name}
1422
+ if draft.original_name is not None:
1423
+ target_names.add(draft.original_name)
1424
+ referencing = self._referencing_profiles_for_delete(draft, target_names)
1425
+ if referencing:
1426
+ preview = ", ".join(referencing[:3])
1427
+ suffix = (
1428
+ self._render_toast(_AGENT_MORE_SUFFIX.bind(extra_count=len(referencing) - 3))
1429
+ if len(referencing) > 3
1430
+ else ""
1431
+ )
1432
+ self.notify(
1433
+ self._render_toast(_AGENT_STILL_REFERENCED.bind(name=name, preview=preview, suffix=suffix)),
1434
+ title=self._render_toast(_AGENT_TITLE.bind()),
1435
+ severity="warning",
1436
+ timeout=6,
1437
+ markup=False,
1438
+ )
1439
+ return
1440
+ display = draft.profile.display_name or name
1441
+ from chrys.app.tui.screens.dialogs.confirm import ConfirmDialog
1442
+
1443
+ dialog = ConfirmDialog(
1444
+ title=_DELETE_AGENT_TITLE.bind(),
1445
+ message=_DELETE_AGENT_MESSAGE.bind(display=display),
1446
+ confirm_label=_DELETE.bind(),
1447
+ confirm_variant="error",
1448
+ locale_controller=widget_locale_controller(self),
1449
+ )
1450
+ self.app.push_screen(
1451
+ dialog,
1452
+ callback=lambda confirmed: self._do_delete(draft_key) if confirmed else None,
1453
+ )
1454
+
1455
+ def _referencing_profiles_for_delete(self, draft: _AgentDraft, target_names: set[str]) -> list[str]:
1456
+ referencing: list[str] = []
1457
+ seen_names: set[str] = set()
1458
+
1459
+ def add_if_referencing(profile_name: str, display_name: str, refs) -> None:
1460
+ if profile_name in target_names or profile_name in seen_names:
1461
+ return
1462
+ if any(ref.profile in target_names for ref in refs):
1463
+ seen_names.add(profile_name)
1464
+ referencing.append(display_name or profile_name)
1465
+
1466
+ for other in self._visible_drafts():
1467
+ if other.key == draft.key:
1468
+ continue
1469
+ add_if_referencing(other.profile.name, other.profile.display_name, other.profile.sub_agents.agents)
1470
+
1471
+ represented_originals = {
1472
+ other.original_name for other in self._visible_drafts() if other.original_name is not None
1473
+ }
1474
+ for profile in self._registry.list_profiles(include_sub_agent_only=True):
1475
+ if profile.name in represented_originals:
1476
+ continue
1477
+ add_if_referencing(profile.name, profile.display_name, profile.sub_agents.agents)
1478
+
1479
+ return referencing
1480
+
1481
+ def _remaining_visible_after_delete(self, draft: _AgentDraft) -> list[AgentProfile]:
1482
+ if draft.original_name is None:
1483
+ return [d.profile for d in self._visible_drafts() if d.key != draft.key]
1484
+ return [
1485
+ profile
1486
+ for profile in self._registry.list_profiles(include_sub_agent_only=True)
1487
+ if profile.name != draft.original_name
1488
+ ]
1489
+
1490
+ def _do_delete(self, draft_key: str) -> None:
1491
+ """Delete the profile immediately after user confirmation."""
1492
+ if self._read_only:
1493
+ return
1494
+ from chrys.service.profiles.agents.serializer import delete_profile
1495
+
1496
+ draft = self._drafts.get(draft_key)
1497
+ if draft is None or draft.is_builtin:
1498
+ return
1499
+ old_active_name = self._active_profile_name
1500
+ promoted_profile: AgentProfile | None = None
1501
+
1502
+ if draft.original_name is not None:
1503
+ if draft.original_name == old_active_name:
1504
+ remaining_main = [p for p in self._registry.list_profiles() if p.name != draft.original_name]
1505
+ if not remaining_main:
1506
+ self.notify(
1507
+ self._render_toast(_LAST_MAIN_AGENT.bind()),
1508
+ title=self._render_toast(_AGENT_TITLE.bind()),
1509
+ severity="warning",
1510
+ timeout=5,
1511
+ markup=False,
1512
+ )
1513
+ return
1514
+ promoted_profile = remaining_main[0]
1515
+ delete_profile(draft.original_name)
1516
+ self._registry.remove(draft.original_name)
1517
+ self._set_dismiss_intent(_DismissIntent.UPDATED)
1518
+ if promoted_profile is not None:
1519
+ self._active_profile_name = promoted_profile.name
1520
+ self._current_profile = promoted_profile.display_name or promoted_profile.name
1521
+ self._set_dismiss_intent(_DismissIntent.SWITCHED)
1522
+ if self._on_saved is not None:
1523
+ self._on_saved(None, promoted_profile.name)
1524
+
1525
+ self._drafts.pop(draft_key, None)
1526
+
1527
+ self._populate_sidebar()
1528
+ remaining = self._visible_drafts()
1529
+ if remaining:
1530
+ next_key = remaining[0].key
1531
+ ol = self.query_one("#ac-list", OptionList)
1532
+ with contextlib.suppress(OptionDoesNotExist):
1533
+ ol.highlighted = ol.get_option_index(next_key)
1534
+ self._load_profile(next_key)
1535
+ self.notify(
1536
+ self._render_toast((_AGENT_DELETED if draft.original_name is not None else _AGENT_REMOVED).bind()),
1537
+ title=self._render_toast(_AGENT_SETTINGS_TITLE.bind()),
1538
+ severity="information",
1539
+ timeout=3,
1540
+ markup=False,
1541
+ )
1542
+ self._update_save_button_state()
1543
+
1544
+ @on(Button.Pressed, "#ac-save")
1545
+ def _on_save(self, _event: Button.Pressed) -> None:
1546
+ if self._read_only:
1547
+ return
1548
+ if not self._hydrating:
1549
+ errors = self._validate_all()
1550
+ if errors:
1551
+ self._unfold_invalid_mcp_cards()
1552
+ self.notify(
1553
+ "\n".join(errors),
1554
+ title=self._render_toast(_VALIDATION_ERROR_TITLE.bind()),
1555
+ severity="error",
1556
+ timeout=5,
1557
+ markup=False,
1558
+ )
1559
+ return
1560
+ try:
1561
+ self._sync_selected_draft_from_panels()
1562
+ except Exception as exc:
1563
+ self.notify(
1564
+ str(exc),
1565
+ title=self._render_toast(_SAVE_ERROR_TITLE.bind()),
1566
+ severity="error",
1567
+ timeout=5,
1568
+ markup=False,
1569
+ )
1570
+ return
1571
+ draft_snapshot = self._snapshot_drafts()
1572
+ retargeted_profiles = self._retarget_renamed_sub_agent_refs()
1573
+ errors = self._validate_drafts(retargeted_profiles)
1574
+ if errors:
1575
+ # Read before the restore: it can clear a dirty flag the retargeting set for this pass.
1576
+ invalid_mcp_servers = self._selected_draft_invalid_mcp_servers()
1577
+ self._restore_drafts(draft_snapshot)
1578
+ self._unfold_invalid_mcp_cards(invalid_mcp_servers)
1579
+ self.notify(
1580
+ "\n".join(errors),
1581
+ title=self._render_toast(_VALIDATION_ERROR_TITLE.bind()),
1582
+ severity="error",
1583
+ timeout=5,
1584
+ markup=False,
1585
+ )
1586
+ return
1587
+ try:
1588
+ self._apply_all(retargeted_profiles)
1589
+ except Exception as exc:
1590
+ self._restore_drafts(draft_snapshot)
1591
+ self.notify(
1592
+ str(exc),
1593
+ title=self._render_toast(_SAVE_ERROR_TITLE.bind()),
1594
+ severity="error",
1595
+ timeout=5,
1596
+ markup=False,
1597
+ )
1598
+ return
1599
+ self.notify(
1600
+ self._render_toast(_AGENT_PROFILE_SAVED.bind()),
1601
+ title=self._render_toast(_AGENT_SETTINGS_TITLE.bind()),
1602
+ severity="information",
1603
+ timeout=3,
1604
+ markup=False,
1605
+ )
1606
+
1607
+ def _render_toast(self, reference: MessageRef) -> str:
1608
+ return render_str(widget_localizer(self), reference)
1609
+
1610
+ @on(Button.Pressed, "#ac-cancel")
1611
+ def _on_cancel_btn(self, _event: Button.Pressed) -> None:
1612
+ self.dismiss(self._cancel_result())
1613
+
1614
+ def action_cancel(self) -> None:
1615
+ self.dismiss(self._cancel_result())
1616
+
1617
+ def _cancel_result(self) -> str:
1618
+ """Return the dismiss result when the user closes the modal.
1619
+
1620
+ ``"switched"`` when a delete promoted a different profile to
1621
+ active — the caller rebuilds the engine against that profile,
1622
+ even if a later Save updated the promoted profile in-place.
1623
+ ``"updated"`` when any Save happened during the session (or the
1624
+ active profile was edited mid-modal and already synced).
1625
+ ``""`` otherwise.
1626
+ """
1627
+ if self._read_only:
1628
+ return ""
1629
+ return self._dismiss_intent.value
1630
+
1631
+ def _default_dismiss_result(self) -> str: # type: ignore[override]
1632
+ return self._cancel_result()
1633
+
1634
+ # ── Validation ───────────────────────────────────────────────────
1635
+
1636
+ def _validate_all(self) -> list[str]:
1637
+ from chrys.app.tui.screens.agents.panels.acp import AcpConfigPanel
1638
+ from chrys.app.tui.screens.agents.panels.basic import BasicConfigPanel
1639
+ from chrys.app.tui.screens.agents.panels.compaction import CompactionConfigPanel
1640
+ from chrys.app.tui.screens.agents.panels.instructions import InstructionsConfigPanel
1641
+ from chrys.app.tui.screens.agents.panels.mcp import MCPConfigPanel
1642
+ from chrys.app.tui.screens.agents.panels.memory import MemoryConfigPanel
1643
+ from chrys.app.tui.screens.agents.panels.skills import SkillsConfigPanel
1644
+ from chrys.app.tui.screens.agents.panels.subagents import SubAgentsConfigPanel
1645
+ from chrys.app.tui.screens.agents.panels.tools import ToolsConfigPanel
1646
+
1647
+ draft = self._drafts.get(self._selected_draft_key)
1648
+ is_acp = draft is not None and draft.profile.acp is not None
1649
+ panels = (
1650
+ (("basic", BasicConfigPanel), ("acp", AcpConfigPanel))
1651
+ if is_acp
1652
+ else (
1653
+ ("basic", BasicConfigPanel),
1654
+ ("instructions", InstructionsConfigPanel),
1655
+ ("tools", ToolsConfigPanel),
1656
+ ("sub-agents", SubAgentsConfigPanel),
1657
+ ("skills", SkillsConfigPanel),
1658
+ ("mcp", MCPConfigPanel),
1659
+ ("memory", MemoryConfigPanel),
1660
+ ("compaction", CompactionConfigPanel),
1661
+ )
1662
+ )
1663
+ errors: list[str] = []
1664
+ for tab_id, panel_cls in panels:
1665
+ if tab_id not in self._mounted_tabs:
1666
+ continue
1667
+ try:
1668
+ errors.extend(self._live_panel(panel_cls).validate())
1669
+ except Exception:
1670
+ logger.debug("Validation skipped for %s", panel_cls.__name__, exc_info=True)
1671
+
1672
+ return errors
1673
+
1674
+ def _selected_draft_invalid_mcp_servers(self) -> set[int]:
1675
+ """Positions of the selected draft's MCP servers that the saved-draft validation rejects.
1676
+
1677
+ That validation checks only dirty drafts: a clean selected draft reported
1678
+ no error, so none of its cards is named by the Save error.
1679
+ """
1680
+ draft = self._drafts.get(self._selected_draft_key)
1681
+ if draft is None or not draft.dirty:
1682
+ return set()
1683
+ return {index for index, errors in enumerate(mcp_draft_errors_by_server("", draft.profile)) if errors}
1684
+
1685
+ def _unfold_invalid_mcp_cards(self, invalid_servers: Collection[int] = ()) -> None:
1686
+ """Unfold the MCP server cards that failed validation: a folded card hides the fields its error names.
1687
+
1688
+ The panel checks its own cards; *invalid_servers* adds the positions
1689
+ Save's saved-draft validation rejected, which checks some fields differently.
1690
+ """
1691
+ from chrys.app.tui.screens.agents.panels.mcp import MCPConfigPanel
1692
+
1693
+ draft = self._drafts.get(self._selected_draft_key)
1694
+ if "mcp" not in self._mounted_tabs or (draft is not None and draft.profile.acp is not None):
1695
+ return
1696
+ try:
1697
+ self._live_panel(MCPConfigPanel).unfold_invalid_cards(invalid_servers)
1698
+ except Exception:
1699
+ # As in _validate_all: a panel that can't validate is skipped, never fails the Save handler.
1700
+ logger.debug("Could not unfold the invalid MCP server cards", exc_info=True)
1701
+
1702
+ def _validate_drafts(self, retargeted_profiles: list[AgentProfile] | None = None) -> list[str]:
1703
+ """Validate the staged draft graph before writing anything to disk."""
1704
+ model_profile_exists: Callable[[str], bool] | None = None
1705
+ model_registry = self._model_registry
1706
+ if model_registry is not None:
1707
+
1708
+ def model_profile_exists(profile_id: str) -> bool:
1709
+ return model_registry.get(profile_id) is not None
1710
+
1711
+ store = AgentDraftStore(
1712
+ self._visible_drafts(),
1713
+ self._registry.list_names(),
1714
+ model_profile_exists=model_profile_exists,
1715
+ workspace_cwd=self._workspace_cwd,
1716
+ workspace_roots=self._workspace_roots,
1717
+ )
1718
+ return store.validate(retargeted_profiles, render_message=self._render_toast)
1719
+
1720
+ # ── Apply ────────────────────────────────────────────────────────
1721
+
1722
+ def _apply_basic(self, updated, basic: dict) -> None:
1723
+ draft = self._drafts.get(self._selected_draft_key)
1724
+ self._apply_basic_to_profile(updated, basic, is_builtin=bool(draft and draft.is_builtin))
1725
+
1726
+ @staticmethod
1727
+ def _apply_basic_to_profile(updated, basic: dict, *, is_builtin: bool) -> None:
1728
+ from chrys.service.profiles.agents.schema import AcpAgentConfig, ModelConfig
1729
+
1730
+ if not is_builtin:
1731
+ updated.name = basic["name"]
1732
+ updated.display_name = basic["display_name"]
1733
+ updated.description = basic["description"]
1734
+ if basic.get("agent_type") == "acp":
1735
+ updated.acp = updated.acp or AcpAgentConfig()
1736
+ updated.sub_agent_only = True
1737
+ updated.model = ModelConfig()
1738
+ else:
1739
+ updated.acp = None
1740
+ updated.sub_agent_only = basic["sub_agent_only"]
1741
+ updated.model = ModelConfig(profile_id=basic.get("model_profile_id", ""))
1742
+
1743
+ def _sync_selected_draft_from_panels(self) -> None:
1744
+ """Harvest the mounted panels into the selected draft profile."""
1745
+ if self._read_only:
1746
+ return
1747
+ if self._hydrating:
1748
+ # During tab/profile hydration the draft is already the source of truth.
1749
+ # Reading panels before their children settle can harvest stale widgets
1750
+ # from the previously mounted profile, especially on slower Windows CI.
1751
+ return
1752
+ draft = self._drafts.get(self._selected_draft_key)
1753
+ if draft is None:
1754
+ return
1755
+ try:
1756
+ draft.profile = self._build_profile_from_mounted_panels(draft)
1757
+ except Exception:
1758
+ # A field that can't be read (an MCP command with an unclosed quote) fails before validation.
1759
+ self._unfold_invalid_mcp_cards()
1760
+ raise
1761
+ self._selected_profile_name = draft.profile.name
1762
+ validation_errors = self._mounted_panel_validation_errors()
1763
+ self._set_draft_dirty(draft, self._is_draft_dirty(draft) or bool(validation_errors))
1764
+ if validation_errors:
1765
+ # Switching still validates the mounted draft strictly because leaving
1766
+ # invalid widget state would make the in-memory transaction ambiguous.
1767
+ # The user stays on this agent: show the cards whose fields the error names.
1768
+ self._unfold_invalid_mcp_cards()
1769
+ error_message = "\n".join(
1770
+ [
1771
+ *validation_errors,
1772
+ self._render_toast(FIX_VALIDATION_ERRORS.bind()),
1773
+ ]
1774
+ )
1775
+ raise RuntimeError(error_message)
1776
+
1777
+ def _retarget_renamed_sub_agent_refs(self) -> list[AgentProfile]:
1778
+ """Keep staged sub-agent references pointed at profiles renamed in this transaction."""
1779
+ name_counts: dict[str, int] = {}
1780
+ for draft in self._visible_drafts():
1781
+ name = draft.profile.name.strip()
1782
+ if name:
1783
+ lowered = name.casefold()
1784
+ name_counts[lowered] = name_counts.get(lowered, 0) + 1
1785
+
1786
+ rename_map = {
1787
+ draft.original_name: draft.profile.name
1788
+ for draft in self._visible_drafts()
1789
+ if draft.original_name
1790
+ and draft.profile.name
1791
+ and draft.original_name != draft.profile.name
1792
+ and re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", draft.profile.name)
1793
+ and name_counts.get(draft.profile.name.casefold()) == 1
1794
+ }
1795
+ if not rename_map:
1796
+ return []
1797
+
1798
+ for draft in self._visible_drafts():
1799
+ changed = False
1800
+ for ref in draft.profile.sub_agents.agents:
1801
+ new_name = rename_map.get(ref.profile)
1802
+ if new_name is None or new_name == ref.profile:
1803
+ continue
1804
+ ref.profile = new_name
1805
+ changed = True
1806
+ if changed:
1807
+ draft.dirty = True
1808
+
1809
+ represented_originals = {
1810
+ draft.original_name for draft in self._visible_drafts() if draft.original_name is not None
1811
+ }
1812
+ retargeted_profiles: list[AgentProfile] = []
1813
+ for profile in self._registry.list_profiles(include_sub_agent_only=True):
1814
+ if profile.name in represented_originals:
1815
+ continue
1816
+ updated = copy.deepcopy(profile)
1817
+ changed = False
1818
+ for ref in updated.sub_agents.agents:
1819
+ new_name = rename_map.get(ref.profile)
1820
+ if new_name is None or new_name == ref.profile:
1821
+ continue
1822
+ ref.profile = new_name
1823
+ changed = True
1824
+ if changed:
1825
+ retargeted_profiles.append(updated)
1826
+ return retargeted_profiles
1827
+
1828
+ def _live_panel[P: Widget](self, panel_cls: type[P]) -> P:
1829
+ """Return the mounted ``panel_cls`` panel, skipping one that a profile load is replacing.
1830
+
1831
+ ``_load_profile`` removes the old panels without waiting, and a removed
1832
+ panel stays in the DOM, ahead of its replacement, until all its children
1833
+ have exited. Reading it then yields its previous profile's values for the
1834
+ widgets already gone, which a harvest would write into the new draft.
1835
+ """
1836
+ for panel in self.query(panel_cls).results(panel_cls):
1837
+ if not panel._pruning:
1838
+ return panel
1839
+ raise NoMatches(f"No {panel_cls.__name__} is mounted")
1840
+
1841
+ def _build_profile_from_mounted_panels(self, draft: _AgentDraft):
1842
+ """Return a profile copy built from the currently mounted tab panels."""
1843
+ from chrys.app.tui.screens.agents.panels.acp import AcpConfigPanel
1844
+ from chrys.app.tui.screens.agents.panels.basic import BasicConfigPanel
1845
+ from chrys.app.tui.screens.agents.panels.compaction import CompactionConfigPanel
1846
+ from chrys.app.tui.screens.agents.panels.instructions import InstructionsConfigPanel
1847
+ from chrys.app.tui.screens.agents.panels.mcp import MCPConfigPanel
1848
+ from chrys.app.tui.screens.agents.panels.memory import MemoryConfigPanel
1849
+ from chrys.app.tui.screens.agents.panels.skills import SkillsConfigPanel
1850
+ from chrys.app.tui.screens.agents.panels.subagents import SubAgentsConfigPanel
1851
+ from chrys.app.tui.screens.agents.panels.tools import ToolsConfigPanel
1852
+
1853
+ builtin_panels: list[tuple[str, MessageDef, type[Any], Callable[[AgentProfile, Any], None]]] = [
1854
+ (
1855
+ "basic",
1856
+ _TAB_BASIC,
1857
+ BasicConfigPanel,
1858
+ lambda p, cfg: self._apply_basic_to_profile(p, cfg, is_builtin=draft.is_builtin),
1859
+ ),
1860
+ (
1861
+ "instructions",
1862
+ _TAB_INSTRUCTIONS,
1863
+ InstructionsConfigPanel,
1864
+ lambda p, cfg: setattr(p, "instructions", cfg),
1865
+ ),
1866
+ ("tools", _TAB_TOOLS, ToolsConfigPanel, lambda p, cfg: setattr(p.tools, "builtins", cfg)),
1867
+ ("sub-agents", _TAB_SUB_AGENTS, SubAgentsConfigPanel, lambda p, cfg: setattr(p, "sub_agents", cfg)),
1868
+ ("skills", _TAB_SKILLS, SkillsConfigPanel, lambda p, cfg: setattr(p, "skills", cfg)),
1869
+ ("mcp", _TAB_MCP, MCPConfigPanel, lambda p, cfg: setattr(p.tools, "mcp", cfg)),
1870
+ ("memory", _TAB_MEMORY, MemoryConfigPanel, lambda p, cfg: setattr(p, "memory", cfg)),
1871
+ ("compaction", _TAB_COMPACTION, CompactionConfigPanel, lambda p, cfg: setattr(p, "compaction", cfg)),
1872
+ ]
1873
+ panels: list[tuple[str, MessageDef, type[Any], Callable[[AgentProfile, Any], None]]] = (
1874
+ [
1875
+ (
1876
+ "basic",
1877
+ _TAB_BASIC,
1878
+ BasicConfigPanel,
1879
+ lambda p, cfg: self._apply_basic_to_profile(p, cfg, is_builtin=draft.is_builtin),
1880
+ ),
1881
+ ("acp", _TAB_ACP_SHORT, AcpConfigPanel, lambda p, cfg: setattr(p, "acp", cfg)),
1882
+ ]
1883
+ if draft.profile.acp is not None
1884
+ else builtin_panels
1885
+ )
1886
+ updated = copy.deepcopy(draft.profile)
1887
+ collected: list[tuple[Callable[[AgentProfile, Any], None], Any]] = []
1888
+ not_ready: list[str] = []
1889
+ failures: list[str] = []
1890
+ for tab_id, tab_title, panel_cls, applier in panels:
1891
+ if tab_id not in self._mounted_tabs:
1892
+ continue
1893
+ try:
1894
+ cfg = self._live_panel(panel_cls).get_config()
1895
+ except NoMatches as exc:
1896
+ not_ready.append(
1897
+ self._render_toast(
1898
+ _TAB_ERROR.bind(
1899
+ tab=self._render_toast(tab_title.bind()),
1900
+ error=DisplayBlock(str(exc)),
1901
+ )
1902
+ )
1903
+ )
1904
+ continue
1905
+ except Exception as exc:
1906
+ logger.debug("Failed to read %s tab config", tab_title.fallback, exc_info=True)
1907
+ failures.append(
1908
+ self._render_toast(
1909
+ _TAB_ERROR.bind(
1910
+ tab=self._render_toast(tab_title.bind()),
1911
+ error=DisplayBlock(str(exc)),
1912
+ )
1913
+ )
1914
+ )
1915
+ continue
1916
+ collected.append((applier, cfg))
1917
+ if not_ready:
1918
+ raise _AgentConfigPanelsNotReady(
1919
+ self._render_toast(_PANELS_STILL_MOUNTING.bind(details=DisplayBlock("\n".join(not_ready))))
1920
+ )
1921
+ if failures:
1922
+ raise RuntimeError(self._render_toast(_PANELS_READ_ERROR.bind(details=DisplayBlock("\n".join(failures)))))
1923
+ for applier, cfg in collected:
1924
+ applier(updated, cfg)
1925
+ self._canonicalize_profile_for_ui(updated)
1926
+ return updated
1927
+
1928
+ @staticmethod
1929
+ def _profile_yaml_path(name: str) -> Path:
1930
+ from chrys.foundation.platform import get_platform
1931
+
1932
+ return get_platform().config_dir / "agents" / f"{name}.yaml"
1933
+
1934
+ def _snapshot_save_state(self, affected_names: set[str]) -> _AgentSaveSnapshot:
1935
+ affected_paths = {self._profile_yaml_path(name) for name in affected_names}
1936
+ return _AgentSaveSnapshot(
1937
+ files={path: path.read_bytes() if path.is_file() else None for path in affected_paths},
1938
+ registry=self._registry.snapshot(),
1939
+ current_profile=self._current_profile,
1940
+ active_profile_name=self._active_profile_name,
1941
+ )
1942
+
1943
+ def _restore_save_state(self, snapshot: _AgentSaveSnapshot) -> None:
1944
+ self._current_profile = snapshot.current_profile
1945
+ self._active_profile_name = snapshot.active_profile_name
1946
+ self._registry.restore(snapshot.registry)
1947
+ from chrys.foundation.platform.files import atomic_write_owner_only_bytes
1948
+
1949
+ for path, content in snapshot.files.items():
1950
+ if content is None:
1951
+ with contextlib.suppress(FileNotFoundError):
1952
+ path.unlink()
1953
+ else:
1954
+ # Same writer as save_profile: a rolled-back file must keep the
1955
+ # owner-only mode the serializer gave it, not the umask default.
1956
+ atomic_write_owner_only_bytes(path, content)
1957
+
1958
+ def _old_profile_path_is_still_targeted(self, old_name: str, target_names: set[str]) -> bool:
1959
+ old_path = self._profile_yaml_path(old_name)
1960
+ for target_name in target_names:
1961
+ target_path = self._profile_yaml_path(target_name)
1962
+ if old_path == target_path:
1963
+ return True
1964
+ with contextlib.suppress(OSError):
1965
+ if old_path.exists() and target_path.exists() and old_path.samefile(target_path):
1966
+ return True
1967
+ return False
1968
+
1969
+ def _should_persist_retargeted_profile(self, profile: AgentProfile) -> bool:
1970
+ return not self._registry.is_builtin(profile.name) or self._profile_yaml_path(profile.name).is_file()
1971
+
1972
+ def _profile_matches_builtin_template(self, profile: AgentProfile, builtin_name: str) -> bool:
1973
+ template = self._registry.get_builtin_template(builtin_name)
1974
+ if template is None:
1975
+ return False
1976
+ from chrys.service.profiles.agents.serializer import profile_to_dict
1977
+
1978
+ candidate = copy.deepcopy(profile)
1979
+ self._canonicalize_profile_for_ui(candidate)
1980
+ self._canonicalize_profile_for_ui(template)
1981
+ return profile_to_dict(candidate) == profile_to_dict(template)
1982
+
1983
+ def _reset_matches_builtin_template(self, draft: _AgentDraft) -> bool:
1984
+ return (
1985
+ draft.reset_to_builtin
1986
+ and draft.original_name is not None
1987
+ and self._profile_matches_builtin_template(draft.profile, draft.original_name)
1988
+ )
1989
+
1990
+ def _perform_save(
1991
+ self,
1992
+ dirty_drafts: list[_AgentDraft],
1993
+ retargeted_profiles: list[AgentProfile],
1994
+ *,
1995
+ old_names: set[str],
1996
+ target_names: set[str],
1997
+ old_active_name: str,
1998
+ ) -> tuple[str | None, str | None]:
1999
+ from chrys.service.profiles.agents.serializer import delete_profile, save_profile
2000
+
2001
+ # Write everything before deleting anything: a save that fails after an
2002
+ # exact-template reset already removed its shadow would leave the
2003
+ # rollback to recreate that file instead of never touching it.
2004
+ reset_drafts: list[_AgentDraft] = []
2005
+ for draft in dirty_drafts:
2006
+ if self._reset_matches_builtin_template(draft):
2007
+ reset_drafts.append(draft)
2008
+ else:
2009
+ save_profile(draft.profile)
2010
+ for profile in retargeted_profiles:
2011
+ if self._should_persist_retargeted_profile(profile):
2012
+ save_profile(profile)
2013
+ for draft in reset_drafts:
2014
+ delete_profile(draft.profile.name)
2015
+
2016
+ for old_name in old_names:
2017
+ old_path = self._profile_yaml_path(old_name)
2018
+ if not self._old_profile_path_is_still_targeted(old_name, target_names) and old_path.is_file():
2019
+ old_path.unlink()
2020
+
2021
+ dirty_target_names = {draft.profile.name for draft in dirty_drafts}
2022
+ for old_name in old_names:
2023
+ # Registry keys are intentionally case-sensitive here: case-only renames
2024
+ # need only the old exact key removed, without matching the new key.
2025
+ # cascade=False is required because refs were already retargeted; cascading
2026
+ # here would strip valid parent refs to the renamed profile.
2027
+ if old_name not in dirty_target_names:
2028
+ self._registry.remove(old_name, cascade=False)
2029
+
2030
+ active_new_display: str | None = None
2031
+ active_new_name: str | None = None
2032
+ for draft in dirty_drafts:
2033
+ updated = draft.profile
2034
+ old_name = draft.original_name
2035
+ self._registry.register(updated)
2036
+
2037
+ # Detect edits to the session-active profile so the main
2038
+ # screen can refresh its input-bar tag / subtitle on Save.
2039
+ if old_name == old_active_name:
2040
+ new_display = updated.display_name or updated.name
2041
+ if new_display != self._current_profile:
2042
+ active_new_display = new_display
2043
+ if updated.name != self._active_profile_name:
2044
+ active_new_name = updated.name
2045
+
2046
+ for profile in retargeted_profiles:
2047
+ self._registry.register(profile)
2048
+ return active_new_display, active_new_name
2049
+
2050
+ def _apply_all(self, retargeted_profiles: list[AgentProfile] | None = None) -> None:
2051
+ if self._read_only:
2052
+ return
2053
+ dirty_drafts = [draft for draft in self._drafts.values() if draft.dirty]
2054
+ retargeted_profiles = retargeted_profiles or []
2055
+ if not dirty_drafts and not retargeted_profiles:
2056
+ return
2057
+
2058
+ old_active_name = self._active_profile_name
2059
+ active_new_display: str | None = None
2060
+ active_new_name: str | None = None
2061
+
2062
+ renamed_drafts = [
2063
+ draft for draft in dirty_drafts if draft.original_name and draft.profile.name != draft.original_name
2064
+ ]
2065
+
2066
+ old_names = {draft.original_name for draft in renamed_drafts if draft.original_name is not None}
2067
+ target_names = {draft.profile.name for draft in dirty_drafts}
2068
+ target_names.update(
2069
+ profile.name for profile in retargeted_profiles if self._should_persist_retargeted_profile(profile)
2070
+ )
2071
+ snapshot = self._snapshot_save_state(old_names | target_names)
2072
+
2073
+ try:
2074
+ active_new_display, active_new_name = self._perform_save(
2075
+ dirty_drafts,
2076
+ retargeted_profiles,
2077
+ old_names=old_names,
2078
+ target_names=target_names,
2079
+ old_active_name=old_active_name,
2080
+ )
2081
+ except Exception:
2082
+ self._restore_save_state(snapshot)
2083
+ raise
2084
+
2085
+ if active_new_display is not None:
2086
+ self._current_profile = active_new_display
2087
+ if active_new_name is not None:
2088
+ self._active_profile_name = active_new_name
2089
+
2090
+ self._set_dismiss_intent(_DismissIntent.UPDATED)
2091
+ selected_name = self._selected_profile_name
2092
+
2093
+ # Rebuilding the drafts drops their keys: carry each agent's MCP card
2094
+ # folds over by the name it was saved under.
2095
+ self._remember_mcp_card_folds()
2096
+ mcp_card_folds = {
2097
+ self._drafts[key].profile.name: folds for key, folds in self._mcp_card_folds.items() if key in self._drafts
2098
+ }
2099
+
2100
+ # Refresh sidebar — a rename or description change should show
2101
+ # up immediately, and the user may keep editing.
2102
+ self._initialize_drafts()
2103
+ for name, folds in mcp_card_folds.items():
2104
+ if (key := self._find_visible_draft_key(name)) is not None:
2105
+ self._mcp_card_folds[key] = folds
2106
+ self._populate_sidebar()
2107
+ selected_key = self._find_visible_draft_key(selected_name)
2108
+ if selected_key is None:
2109
+ visible = self._visible_drafts()
2110
+ selected_key = visible[0].key if visible else ""
2111
+ ol = self.query_one("#ac-list", OptionList)
2112
+ with contextlib.suppress(OptionDoesNotExist):
2113
+ ol.highlighted = ol.get_option_index(selected_key)
2114
+ if selected_key:
2115
+ # Re-mount all tab panels against the freshly-saved profile.
2116
+ self._load_profile(selected_key, mcp_card_folds_carried=True)
2117
+ self._update_save_button_state()
2118
+
2119
+ # Notify the caller of any active-profile change so main-screen widgets
2120
+ # refresh immediately and registry-name renames queue a switch for Close.
2121
+ if (active_new_display is not None or active_new_name is not None) and self._on_saved is not None:
2122
+ self._on_saved(active_new_display, active_new_name)
2123
+ elif active_new_name is not None:
2124
+ self._set_dismiss_intent(_DismissIntent.SWITCHED)
2125
+
2126
+ # A save can retire the session-active profile from main duty (the
2127
+ # External ACP conversion forces ``sub_agent_only``). The engine must
2128
+ # never be pointed at a sub-agent-only profile — reload rejects an ACP
2129
+ # main and the next startup would refuse the persisted config — so
2130
+ # promote a remaining main-eligible profile, exactly like delete does.
2131
+ # This runs after the rename notification above so its queued switch
2132
+ # overrides a rename that would otherwise re-target the retired name.
2133
+ active = self._registry.get(self._active_profile_name)
2134
+ if active is not None and active.sub_agent_only:
2135
+ remaining_main = [p for p in self._registry.list_profiles() if p.name != active.name]
2136
+ if remaining_main:
2137
+ promoted = remaining_main[0]
2138
+ self._active_profile_name = promoted.name
2139
+ self._current_profile = promoted.display_name or promoted.name
2140
+ self._set_dismiss_intent(_DismissIntent.SWITCHED)
2141
+ if self._on_saved is not None:
2142
+ self._on_saved(None, promoted.name)