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,2043 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """Model configuration modal — manage model profiles with sidebar + form layout."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import contextlib
9
+ import copy
10
+ import json
11
+ import math
12
+ import re
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from typing import TYPE_CHECKING, ClassVar
16
+
17
+ from rich.markup import escape
18
+ from rich.text import Text
19
+ from textual import on
20
+ from textual.containers import Horizontal, Vertical, VerticalScroll
21
+ from textual.content import Content
22
+ from textual.css.query import NoMatches
23
+ from textual.widget import Widget
24
+ from textual.widgets import Button, Label, OptionList, Static
25
+ from textual.widgets.option_list import Option, OptionDoesNotExist
26
+
27
+ from chrys.app.tui.binding_display import CANCEL_BINDING, localized_binding
28
+ from chrys.app.tui.i18n import render_str, widget_locale_controller, widget_localizer
29
+ from chrys.app.tui.screens.dialogs.base import BaseDialog
30
+ from chrys.app.tui.widgets import Checkbox, ConfigAddButton, Select
31
+ from chrys.app.tui.widgets import EnhancedInput as Input
32
+ from chrys.foundation.branding import APP_DISPLAY_NAME
33
+ from chrys.foundation.config.runtime_pointer import set_model_pointer
34
+ from chrys.foundation.config.spec import SettingOrigin, Source
35
+ from chrys.foundation.i18n import DisplayBlock, MessageDef, MessageRef, msg
36
+ from chrys.foundation.i18n.formatting import format_message
37
+ from chrys.foundation.text.reading_order import reading_order
38
+ from chrys.foundation.util.chrys_headers import is_chrys_managed_header_name
39
+ from chrys.foundation.util.header_charset import (
40
+ api_key_charset_error,
41
+ header_name_charset_error,
42
+ header_value_charset_error,
43
+ model_id_charset_error,
44
+ )
45
+ from chrys.service.context.compaction.budgets import MIN_DERIVABLE_CONTEXT_TOKENS
46
+ from chrys.service.llm.providers import CHAT_COMPLETIONS_TOKEN_LIMIT_PARAMS, PROVIDERS
47
+ from chrys.service.profiles.models.options import (
48
+ OUTPUT_CAP_OPTION_ALIASES,
49
+ PROTECTED_EXTRA_BODY_CHAT_OPTION_KEYS,
50
+ PROTECTED_TOP_LEVEL_CHAT_OPTION_KEYS,
51
+ is_anthropic_claude_profile,
52
+ lacks_anthropic_prompt_cache_option,
53
+ with_anthropic_prompt_cache_option,
54
+ )
55
+ from chrys.service.profiles.models.schema import (
56
+ API_STYLE_CHAT_COMPLETIONS,
57
+ API_STYLE_RESPONSES,
58
+ DEFAULT_MAX_CONTEXT_TOKENS,
59
+ DEFAULT_MAX_OUTPUT_TOKENS,
60
+ is_model_profile_selectable,
61
+ is_responses_wire_dialect,
62
+ )
63
+
64
+ if TYPE_CHECKING:
65
+ from textual.app import ComposeResult
66
+
67
+ from chrys.app.tui.screens.dialogs.prompt_cache import PromptCacheDialogResult
68
+ from chrys.service.profiles.models.registry import ModelProfileRegistry
69
+ from chrys.service.profiles.models.schema import ModelProfile
70
+
71
+ _READ_ONLY_NOTICE = msg(
72
+ "tui.model_config.read_only_notice",
73
+ fallback="• Agent is running. This page is read-only.",
74
+ )
75
+
76
+ _MODEL_SETTINGS_TITLE = msg("tui.model_config.title.settings", fallback="Model Settings")
77
+ _VALIDATION_ERROR_TITLE = msg("tui.model_config.title.validation_error", fallback="Validation Error")
78
+ _SAVE_ERROR_TITLE = msg("tui.model_config.title.save_error", fallback="Save Error")
79
+ _MODEL_PROFILE_SAVED = msg("tui.model_config.saved", fallback="Model profile saved")
80
+ _CONFIGURATION_TITLE = msg("tui.model_config.title.configuration", fallback="Model Configuration")
81
+ _PROFILE_NAME = msg("tui.model_config.profile_name", fallback="Profile Name")
82
+ _PROFILE_NAME_PLACEHOLDER = msg(
83
+ "tui.model_config.placeholder.profile_name",
84
+ fallback="e.g. My Model Config",
85
+ )
86
+ _MODEL_OPTIONS = msg("tui.model_config.model_options", fallback="Model Options")
87
+ _PROVIDER = msg("tui.model_config.provider", fallback="Provider")
88
+ _API_STYLE = msg("tui.model_config.api_style", fallback="API Style")
89
+ _MODEL = msg("tui.model_config.model", fallback="Model")
90
+ _MODEL_PLACEHOLDER = msg(
91
+ "tui.model_config.placeholder.model",
92
+ fallback="Provider-specific model id (required)",
93
+ )
94
+ _MAX_CONTEXT_WINDOW = msg("tui.model_config.max_context_window", fallback="Max Context Window")
95
+ _MAX_OUTPUT_TOKENS = msg(
96
+ "tui.model_config.max_output_tokens",
97
+ fallback="Max Output Tokens ({param})",
98
+ )
99
+ _OUTPUT_CAP_PLACEHOLDER = msg(
100
+ "tui.model_config.placeholder.output_cap",
101
+ fallback="Model output cap (default {default})",
102
+ )
103
+ _BASE_URL = msg("tui.model_config.base_url", fallback="Base URL")
104
+ _API_KEY = msg("tui.model_config.api_key", fallback="API Key")
105
+ _STREAMING = msg("tui.model_config.streaming", fallback="Streaming")
106
+ _VISION = msg("tui.model_config.vision", fallback="Vision Model")
107
+ _CONNECTION_OPTIONS = msg("tui.model_config.connection_options", fallback="Connection Options")
108
+ _SKIP_TLS_VERIFICATION = msg("tui.model_config.skip_tls_verification", fallback="Skip TLS verification")
109
+ _INSECURE_TLS = msg(
110
+ "tui.model_config.insecure_tls",
111
+ fallback="Insecure: disables HTTPS certificate validation. Use only for trusted endpoints.",
112
+ )
113
+ _BYPASS_PROXY = msg("tui.model_config.bypass_proxy", fallback="Bypass proxy")
114
+ _HTTP_OPTIONS = msg("tui.model_config.http_options", fallback="HTTP Options")
115
+ _HTTP_CONNECT_TIMEOUT = msg("tui.model_config.http_connect_timeout", fallback="HTTP Connect Timeout (s)")
116
+ _HTTP_READ_TIMEOUT = msg("tui.model_config.http_read_timeout", fallback="HTTP Read Timeout (s)")
117
+ _HTTP_MAX_RETRIES = msg("tui.model_config.http_max_retries", fallback="HTTP Max Retries")
118
+ _EXTRA_OPTIONS = msg("tui.model_config.extra_options", fallback="Extra Options")
119
+ _HTTP_EXTRA_HEADERS = msg("tui.model_config.http_extra_headers", fallback="HTTP Extra Headers")
120
+ _CHAT_OPTIONS = msg("tui.model_config.chat_options", fallback="Chat Options")
121
+ _NEW = msg("tui.model_config.button.new", fallback="New")
122
+ _CLONE = msg("tui.model_config.button.clone", fallback="Clone")
123
+ _DELETE = msg("tui.model_config.button.delete", fallback="Delete")
124
+ _SAVE = msg("tui.model_config.button.save", fallback="Save")
125
+ _CLOSE = msg("tui.model_config.button.close", fallback="Close")
126
+ _ADD = msg("tui.model_config.button.add", fallback="+ Add")
127
+ _HEADER_NAME_PLACEHOLDER = msg(
128
+ "tui.model_config.placeholder.header_name",
129
+ fallback="e.g. X-Auth-Token",
130
+ )
131
+ _HEADER_VALUE_PLACEHOLDER = msg(
132
+ "tui.model_config.placeholder.header_value",
133
+ fallback="e.g. {{{{AUTH_TOKEN}}}}",
134
+ )
135
+ _OPTION_NAME_PLACEHOLDER = msg(
136
+ "tui.model_config.placeholder.option_name",
137
+ fallback="e.g. extra_body, temperature",
138
+ )
139
+ _OPTION_VALUE_PLACEHOLDER = msg(
140
+ "tui.model_config.placeholder.option_value",
141
+ fallback="e.g. 0.7, true, or {{...}}",
142
+ )
143
+ _PROVIDER_MODEL = msg("tui.model_config.provider_model", fallback="{provider} Model")
144
+ _PROVIDER_BASE_URL = msg("tui.model_config.provider_base_url", fallback="{provider} Base URL")
145
+ _PROVIDER_API_KEY = msg("tui.model_config.provider_api_key", fallback="{provider} API Key")
146
+ _LEAVE_BLANK_FOR_DEFAULT = msg(
147
+ "tui.model_config.placeholder.leave_blank_for_default",
148
+ fallback="Leave blank for default",
149
+ )
150
+ _DELETE_PROFILE_TITLE = msg("tui.model_config.confirm_delete.title", fallback="Delete Model Profile")
151
+ _DELETE_PROFILE_MESSAGE = msg(
152
+ "tui.model_config.confirm_delete.message",
153
+ fallback='Delete model profile\n"{display}"?\n\nThis cannot be undone.',
154
+ multiline=True,
155
+ )
156
+
157
+ # Validation prose.
158
+ _CHAT_OPTION_OBJECT_EXAMPLE = msg(
159
+ "tui.model_config.validation.chat_option.object_example",
160
+ fallback="Chat Options row {row}: '{key}' must be a valid JSON object/map, for example {example}.",
161
+ multiline=True,
162
+ )
163
+ _CHAT_OPTION_OBJECT_TYPE = msg(
164
+ "tui.model_config.validation.chat_option.object_type",
165
+ fallback="Chat Options row {row}: '{key}' must be a JSON object/map, got {type_name}.",
166
+ multiline=True,
167
+ )
168
+ _JSON_NUMBER = msg("tui.model_config.validation.clause.json_number", fallback="a JSON number")
169
+ _JSON_NUMBER_MIN = msg(
170
+ "tui.model_config.validation.clause.json_number_min",
171
+ fallback="a JSON number greater than or equal to {minimum}",
172
+ )
173
+ _JSON_NUMBER_MAX = msg(
174
+ "tui.model_config.validation.clause.json_number_max",
175
+ fallback="a JSON number less than or equal to {maximum}",
176
+ )
177
+ _JSON_NUMBER_RANGE = msg(
178
+ "tui.model_config.validation.clause.json_number_range",
179
+ fallback="a JSON number between {minimum} and {maximum}",
180
+ )
181
+ _JSON_INTEGER = msg("tui.model_config.validation.clause.json_integer", fallback="a JSON integer")
182
+ _JSON_POSITIVE_INTEGER = msg(
183
+ "tui.model_config.validation.clause.json_positive_integer",
184
+ fallback="a positive JSON integer",
185
+ )
186
+ _JSON_INTEGER_MIN = msg(
187
+ "tui.model_config.validation.clause.json_integer_min",
188
+ fallback="a JSON integer greater than or equal to {minimum}",
189
+ )
190
+ _JSON_INTEGER_MAX = msg(
191
+ "tui.model_config.validation.clause.json_integer_max",
192
+ fallback="a JSON integer less than or equal to {maximum}",
193
+ )
194
+ _JSON_INTEGER_RANGE = msg(
195
+ "tui.model_config.validation.clause.json_integer_range",
196
+ fallback="a JSON integer between {minimum} and {maximum}",
197
+ )
198
+ _CHAT_OPTION_MUST_BE = msg(
199
+ "tui.model_config.validation.chat_option.must_be",
200
+ fallback="Chat Options row {row}: '{key}' must be {clause}.",
201
+ multiline=True,
202
+ )
203
+ _CHAT_OPTION_BOOLEAN = msg(
204
+ "tui.model_config.validation.chat_option.boolean",
205
+ fallback="Chat Options row {row}: '{key}' must be a JSON boolean (true or false).",
206
+ multiline=True,
207
+ )
208
+ _CHAT_OPTION_STRING = msg(
209
+ "tui.model_config.validation.chat_option.string",
210
+ fallback="Chat Options row {row}: '{key}' must be a string.",
211
+ multiline=True,
212
+ )
213
+ _CHAT_OPTION_DETAIL = msg(
214
+ "tui.model_config.validation.chat_option.detail",
215
+ fallback="Chat Options row {row}: '{key}': {detail}",
216
+ multiline=True,
217
+ )
218
+ _CHAT_OPTION_STOP = msg(
219
+ "tui.model_config.validation.chat_option.stop",
220
+ fallback="Chat Options row {row}: 'stop' must be a string or a JSON array of strings.",
221
+ )
222
+ _CHAT_OPTION_LOGIT_BIAS = msg(
223
+ "tui.model_config.validation.chat_option.logit_bias",
224
+ fallback=(
225
+ "Chat Options row {row}: 'logit_bias' value for token {token} must be a JSON number between -100 and 100."
226
+ ),
227
+ multiline=True,
228
+ )
229
+ _CHAT_OPTION_HEADER_RESERVED = msg(
230
+ "tui.model_config.validation.chat_option.header_reserved",
231
+ fallback="Chat Options row {row}: 'extra_headers' header {header} is reserved for {app}.",
232
+ multiline=True,
233
+ )
234
+ _CHAT_OPTION_HEADER_VALUE_STRING = msg(
235
+ "tui.model_config.validation.chat_option.header_value_string",
236
+ fallback="Chat Options row {row}: 'extra_headers' value for header {header} must be a string.",
237
+ multiline=True,
238
+ )
239
+ _CHAT_OPTION_OUTPUT_CAP = msg(
240
+ "tui.model_config.validation.chat_option.output_cap",
241
+ fallback=(
242
+ "Chat Options row {row}: {key} is not saved on model profiles — remove this row and set the Max Output "
243
+ "Tokens field above instead."
244
+ ),
245
+ multiline=True,
246
+ )
247
+ _CHAT_OPTION_PROTECTED = msg(
248
+ "tui.model_config.validation.chat_option.protected",
249
+ fallback="Chat Options row {row}: {key} is protected because it bypasses context admission.",
250
+ multiline=True,
251
+ )
252
+ _CHAT_OPTION_NESTED_PROTECTED = msg(
253
+ "tui.model_config.validation.chat_option.nested_protected",
254
+ fallback="Chat Options row {row}: extra_body contains protected key(s): {keys}.",
255
+ multiline=True,
256
+ )
257
+ _NAME_REQUIRED = msg("tui.model_config.validation.name_required", fallback="Name is required.")
258
+ _PROFILE_EXISTS = msg(
259
+ "tui.model_config.validation.profile_exists",
260
+ fallback="A model profile named '{name}' already exists (names are case-insensitive).",
261
+ multiline=True,
262
+ )
263
+ _PROVIDER_ALLOWED = msg(
264
+ "tui.model_config.validation.provider_allowed",
265
+ fallback="Provider must be one of: {providers}.",
266
+ )
267
+ _MODEL_REQUIRED = msg("tui.model_config.validation.model_required", fallback="Model name is required.")
268
+ _FIELD_REQUIRED_EXAMPLE = msg(
269
+ "tui.model_config.validation.field_required_example",
270
+ fallback="{label} is required (e.g. {example}).",
271
+ )
272
+ _FIELD_VALID_INTEGER = msg(
273
+ "tui.model_config.validation.field_valid_integer",
274
+ fallback="{label} must be a valid integer.",
275
+ )
276
+ _FIELD_POSITIVE_INTEGER = msg(
277
+ "tui.model_config.validation.field_positive_integer",
278
+ fallback="{label} must be a positive integer.",
279
+ )
280
+ _MAX_CONTEXT_TOKENS_LABEL = msg(
281
+ "tui.model_config.validation.label.max_context_tokens",
282
+ fallback="Max context tokens",
283
+ )
284
+ _MAX_OUTPUT_TOKENS_LABEL = msg(
285
+ "tui.model_config.validation.label.max_output_tokens",
286
+ fallback="Max output tokens",
287
+ )
288
+ _MAX_CONTEXT_MINIMUM = msg(
289
+ "tui.model_config.validation.max_context_minimum",
290
+ fallback="Max context tokens must be at least {minimum}.",
291
+ )
292
+ _MAX_OUTPUT_LESS_THAN_CONTEXT = msg(
293
+ "tui.model_config.validation.max_output_less_than_context",
294
+ fallback="Max output tokens must be less than max context tokens.",
295
+ )
296
+ _BASE_URL_SCHEME = msg(
297
+ "tui.model_config.validation.base_url_scheme",
298
+ fallback="Base URL must start with http:// or https://.",
299
+ )
300
+ _HTTP_CONNECT_TIMEOUT_LABEL = msg(
301
+ "tui.model_config.validation.label.http_connect_timeout",
302
+ fallback="HTTP Connect Timeout",
303
+ )
304
+ _HTTP_READ_TIMEOUT_LABEL = msg(
305
+ "tui.model_config.validation.label.http_read_timeout",
306
+ fallback="HTTP Read Timeout",
307
+ )
308
+ _FIELD_POSITIVE_NUMBER = msg(
309
+ "tui.model_config.validation.field_positive_number",
310
+ fallback="{label} must be a positive number.",
311
+ )
312
+ _FIELD_VALID_NUMBER = msg(
313
+ "tui.model_config.validation.field_valid_number",
314
+ fallback="{label} must be a valid number.",
315
+ )
316
+ _HTTP_RETRIES_NON_NEGATIVE = msg(
317
+ "tui.model_config.validation.http_retries_non_negative",
318
+ fallback="HTTP Max Retries must be a non-negative integer.",
319
+ )
320
+ _HTTP_RETRIES_VALID = msg(
321
+ "tui.model_config.validation.http_retries_valid",
322
+ fallback="HTTP Max Retries must be a valid integer.",
323
+ )
324
+ _KV_VALUE_REQUIRED = msg(
325
+ "tui.model_config.validation.kv_value_required",
326
+ fallback="{label} row {row}: value is required for key '{key}'.",
327
+ multiline=True,
328
+ )
329
+ _KV_KEY_REQUIRED = msg(
330
+ "tui.model_config.validation.kv_key_required",
331
+ fallback="{label} row {row}: key name is required when a value is set.",
332
+ )
333
+ _KV_HEADER_RESERVED = msg(
334
+ "tui.model_config.validation.kv_header_reserved",
335
+ fallback="{label} row {row}: header key '{key}' is reserved for {app}.",
336
+ multiline=True,
337
+ )
338
+ _KV_DETAIL = msg(
339
+ "tui.model_config.validation.kv_detail",
340
+ fallback="{label} row {row}: {detail}",
341
+ multiline=True,
342
+ )
343
+ _KV_DUPLICATE_KEY = msg(
344
+ "tui.model_config.validation.kv_duplicate_key",
345
+ fallback="{label} row {row}: duplicate key '{key}'.",
346
+ multiline=True,
347
+ )
348
+ _RESPONSES_STORE_WARNING = msg(
349
+ "tui.model_config.warning.responses_store_continuation",
350
+ fallback=(
351
+ 'chat_options {{"store": true}} enables service-side continuation. Context compaction is not supported in '
352
+ "this mode yet: long sessions will not compact and may overflow the context window."
353
+ ),
354
+ )
355
+ _PROTECTED_CHAT_OPTIONS_WARNING = msg(
356
+ "tui.model_config.warning.protected_chat_options",
357
+ fallback=(
358
+ "Protected chat option key(s) were stripped: {keys}. These keys can no longer be configured in profile YAML "
359
+ "because they bypass context admission. Reusable prompts and profile-pinned continuation IDs are unavailable "
360
+ "there; store: true only enables automatically managed Responses continuation."
361
+ ),
362
+ multiline=True,
363
+ )
364
+
365
+ # ── Provider helpers ─────────────────────────────────────────────────
366
+
367
+ _PROVIDERS = [
368
+ ("OpenAI", "openai"),
369
+ ("Anthropic", "anthropic"),
370
+ ("DeepSeek (OpenAI)", "deepseek-openai"),
371
+ ("GLM (OpenAI)", "glm-openai"),
372
+ ]
373
+ _API_STYLE_CHAT_COMPLETIONS = msg(
374
+ "tui.model_config.api_style.chat_completions",
375
+ fallback="Chat Completions",
376
+ )
377
+ _API_STYLE_RESPONSES = msg("tui.model_config.api_style.responses", fallback="Responses")
378
+ _API_STYLES = [
379
+ (_API_STYLE_CHAT_COMPLETIONS, API_STYLE_CHAT_COMPLETIONS),
380
+ (_API_STYLE_RESPONSES, API_STYLE_RESPONSES),
381
+ ]
382
+
383
+ _PROVIDER_LABELS: dict[str, str] = {
384
+ "openai": "OpenAI",
385
+ "anthropic": "Anthropic",
386
+ "deepseek-openai": "DeepSeek (OpenAI)",
387
+ "glm-openai": "GLM (OpenAI)",
388
+ }
389
+
390
+ _PROVIDER_DEFAULT_BASE_URLS: dict[str, str] = {name: spec.default_base_url for name, spec in PROVIDERS.items()}
391
+
392
+
393
+ def _token_limit_param_name(provider: str, api_style: str) -> str:
394
+ """Wire parameter that carries the Max Output Tokens value for this selection.
395
+
396
+ Chat-completions values come from the table the client variants take their
397
+ ``max_output_param`` from, so the label names what the client sends without
398
+ importing the SDK-backed client modules while the form renders.
399
+ """
400
+ if provider == "anthropic":
401
+ return "max_tokens"
402
+ if is_responses_wire_dialect(provider, api_style):
403
+ return "max_output_tokens"
404
+ return CHAT_COMPLETIONS_TOKEN_LIMIT_PARAMS.get(provider, CHAT_COMPLETIONS_TOKEN_LIMIT_PARAMS["openai"])
405
+
406
+
407
+ _JSON_OBJECT_CHAT_OPTION_KEYS = frozenset(
408
+ {
409
+ "extra_body",
410
+ "extra_headers",
411
+ "extra_query",
412
+ "logit_bias",
413
+ "metadata",
414
+ "response_format",
415
+ }
416
+ )
417
+ _FLOAT_CHAT_OPTION_RULES: dict[str, tuple[float | None, float | None]] = {
418
+ "temperature": (0.0, 2.0),
419
+ "top_p": (0.0, 1.0),
420
+ "frequency_penalty": (-2.0, 2.0),
421
+ "presence_penalty": (-2.0, 2.0),
422
+ }
423
+ _INT_CHAT_OPTION_RULES: dict[str, tuple[int | None, int | None]] = {
424
+ "seed": (None, None),
425
+ }
426
+ _BOOL_CHAT_OPTION_KEYS = frozenset({"allow_multiple_tool_calls", "store"})
427
+ _STRING_CHAT_OPTION_KEYS = frozenset({"conversation_id", "instructions", "model", "user"})
428
+ _CHAT_OPTION_INVALID_JSON_VALUE = object()
429
+ type _RenderMessage = Callable[[MessageRef], str]
430
+
431
+ # ── UID counter for key-value rows ──────────────────────────────────
432
+
433
+ _uid_counter = 0
434
+
435
+
436
+ def _next_uid() -> int:
437
+ global _uid_counter
438
+ _uid_counter += 1
439
+ return _uid_counter
440
+
441
+
442
+ def _loads_or(text: str, default: object) -> object:
443
+ """Return ``json.loads(text)``, or *default* for any text JSON rejects.
444
+
445
+ Catches ``ValueError``, not only ``JSONDecodeError``: an integer past
446
+ Python's digit limit raises the plain base class.
447
+ """
448
+ try:
449
+ return json.loads(text)
450
+ except ValueError:
451
+ return default
452
+
453
+
454
+ def _parse_chat_option_value_for_validation(raw: str) -> object:
455
+ """Parse a Chat Options value for validation."""
456
+ return _loads_or(raw, _CHAT_OPTION_INVALID_JSON_VALUE)
457
+
458
+
459
+ def _validate_chat_option_object(
460
+ index: int,
461
+ key: str,
462
+ parsed: object,
463
+ *,
464
+ render_message: _RenderMessage = format_message,
465
+ ) -> list[str]:
466
+ if parsed is _CHAT_OPTION_INVALID_JSON_VALUE:
467
+ example = '{"key": "value"}'
468
+ return [
469
+ render_message(
470
+ _CHAT_OPTION_OBJECT_EXAMPLE.bind(
471
+ row=index,
472
+ key=DisplayBlock(key),
473
+ example=DisplayBlock(example),
474
+ )
475
+ )
476
+ ]
477
+ if not isinstance(parsed, dict):
478
+ return [
479
+ render_message(
480
+ _CHAT_OPTION_OBJECT_TYPE.bind(
481
+ row=index,
482
+ key=DisplayBlock(key),
483
+ type_name=type(parsed).__name__,
484
+ )
485
+ )
486
+ ]
487
+ return []
488
+
489
+
490
+ def _json_number_description(
491
+ min_value: float | None,
492
+ max_value: float | None,
493
+ *,
494
+ render_message: _RenderMessage = format_message,
495
+ ) -> str:
496
+ if min_value is not None and max_value is not None:
497
+ return render_message(
498
+ _JSON_NUMBER_RANGE.bind(
499
+ minimum=f"{min_value:.1f}",
500
+ maximum=f"{max_value:.1f}",
501
+ )
502
+ )
503
+ if min_value is not None:
504
+ return render_message(_JSON_NUMBER_MIN.bind(minimum=f"{min_value:.1f}"))
505
+ if max_value is not None:
506
+ return render_message(_JSON_NUMBER_MAX.bind(maximum=f"{max_value:.1f}"))
507
+ return render_message(_JSON_NUMBER.bind())
508
+
509
+
510
+ def _json_integer_description(
511
+ min_value: int | None,
512
+ max_value: int | None,
513
+ *,
514
+ render_message: _RenderMessage = format_message,
515
+ ) -> str:
516
+ if min_value == 1 and max_value is None:
517
+ return render_message(_JSON_POSITIVE_INTEGER.bind())
518
+ if min_value is not None and max_value is not None:
519
+ return render_message(_JSON_INTEGER_RANGE.bind(minimum=min_value, maximum=max_value))
520
+ if min_value is not None:
521
+ return render_message(_JSON_INTEGER_MIN.bind(minimum=min_value))
522
+ if max_value is not None:
523
+ return render_message(_JSON_INTEGER_MAX.bind(maximum=max_value))
524
+ return render_message(_JSON_INTEGER.bind())
525
+
526
+
527
+ def _validate_chat_option_float(
528
+ index: int,
529
+ key: str,
530
+ parsed: object,
531
+ *,
532
+ render_message: _RenderMessage = format_message,
533
+ ) -> list[str]:
534
+ min_value, max_value = _FLOAT_CHAT_OPTION_RULES[key]
535
+ clause = _json_number_description(min_value, max_value, render_message=render_message)
536
+ if isinstance(parsed, bool) or not isinstance(parsed, int | float) or not math.isfinite(float(parsed)):
537
+ return [
538
+ render_message(
539
+ _CHAT_OPTION_MUST_BE.bind(
540
+ row=index,
541
+ key=DisplayBlock(key),
542
+ clause=clause,
543
+ )
544
+ )
545
+ ]
546
+
547
+ value = float(parsed)
548
+ if min_value is not None and value < min_value:
549
+ return [render_message(_CHAT_OPTION_MUST_BE.bind(row=index, key=DisplayBlock(key), clause=clause))]
550
+ if max_value is not None and value > max_value:
551
+ return [render_message(_CHAT_OPTION_MUST_BE.bind(row=index, key=DisplayBlock(key), clause=clause))]
552
+ return []
553
+
554
+
555
+ def _validate_chat_option_int(
556
+ index: int,
557
+ key: str,
558
+ parsed: object,
559
+ *,
560
+ render_message: _RenderMessage = format_message,
561
+ ) -> list[str]:
562
+ min_value, max_value = _INT_CHAT_OPTION_RULES[key]
563
+ clause = _json_integer_description(min_value, max_value, render_message=render_message)
564
+ if isinstance(parsed, bool) or not isinstance(parsed, int):
565
+ return [render_message(_CHAT_OPTION_MUST_BE.bind(row=index, key=DisplayBlock(key), clause=clause))]
566
+
567
+ if min_value is not None and parsed < min_value:
568
+ return [render_message(_CHAT_OPTION_MUST_BE.bind(row=index, key=DisplayBlock(key), clause=clause))]
569
+ if max_value is not None and parsed > max_value:
570
+ return [render_message(_CHAT_OPTION_MUST_BE.bind(row=index, key=DisplayBlock(key), clause=clause))]
571
+ return []
572
+
573
+
574
+ def _validate_chat_option_bool(
575
+ index: int,
576
+ key: str,
577
+ parsed: object,
578
+ *,
579
+ render_message: _RenderMessage = format_message,
580
+ ) -> list[str]:
581
+ if not isinstance(parsed, bool):
582
+ return [render_message(_CHAT_OPTION_BOOLEAN.bind(row=index, key=DisplayBlock(key)))]
583
+ return []
584
+
585
+
586
+ def _chat_option_string_value(parsed: object, raw: str) -> str | None:
587
+ if parsed is _CHAT_OPTION_INVALID_JSON_VALUE:
588
+ return raw
589
+ if isinstance(parsed, str):
590
+ return parsed
591
+ return None
592
+
593
+
594
+ def _validate_chat_option_string(
595
+ index: int,
596
+ key: str,
597
+ parsed: object,
598
+ raw: str,
599
+ *,
600
+ render_message: _RenderMessage = format_message,
601
+ ) -> list[str]:
602
+ value = _chat_option_string_value(parsed, raw)
603
+ if value is None:
604
+ return [render_message(_CHAT_OPTION_STRING.bind(row=index, key=DisplayBlock(key)))]
605
+ if key == "model":
606
+ # A model override replaces the profile's model id on the wire —
607
+ # including the ``Chrys-Model-Id`` header — so it carries the
608
+ # same charset constraint. ``{{ENV_VAR}}`` templates are ASCII
609
+ # and pass; resolved values are re-checked at request time.
610
+ charset_error = model_id_charset_error(value)
611
+ if charset_error:
612
+ return [
613
+ render_message(
614
+ _CHAT_OPTION_DETAIL.bind(
615
+ row=index,
616
+ key=DisplayBlock("model"),
617
+ detail=DisplayBlock(charset_error),
618
+ )
619
+ )
620
+ ]
621
+ return []
622
+
623
+
624
+ def _validate_chat_option_stop(
625
+ index: int,
626
+ parsed: object,
627
+ raw: str,
628
+ *,
629
+ render_message: _RenderMessage = format_message,
630
+ ) -> list[str]:
631
+ if _chat_option_string_value(parsed, raw) is not None:
632
+ return []
633
+ if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed):
634
+ return []
635
+ return [render_message(_CHAT_OPTION_STOP.bind(row=index))]
636
+
637
+
638
+ def _validate_chat_option_logit_bias(
639
+ index: int,
640
+ parsed: object,
641
+ *,
642
+ render_message: _RenderMessage = format_message,
643
+ ) -> list[str]:
644
+ if not isinstance(parsed, dict):
645
+ return _validate_chat_option_object(index, "logit_bias", parsed, render_message=render_message)
646
+
647
+ for token_id, bias in parsed.items():
648
+ if isinstance(bias, bool) or not isinstance(bias, int | float) or not math.isfinite(float(bias)):
649
+ return [
650
+ render_message(
651
+ _CHAT_OPTION_LOGIT_BIAS.bind(
652
+ row=index,
653
+ token=DisplayBlock(repr(token_id)),
654
+ )
655
+ )
656
+ ]
657
+ if not -100 <= float(bias) <= 100:
658
+ return [
659
+ render_message(
660
+ _CHAT_OPTION_LOGIT_BIAS.bind(
661
+ row=index,
662
+ token=DisplayBlock(repr(token_id)),
663
+ )
664
+ )
665
+ ]
666
+ return []
667
+
668
+
669
+ def _validate_chat_option_extra_headers(
670
+ index: int,
671
+ parsed: object,
672
+ *,
673
+ render_message: _RenderMessage = format_message,
674
+ ) -> list[str]:
675
+ if not isinstance(parsed, dict):
676
+ return _validate_chat_option_object(index, "extra_headers", parsed, render_message=render_message)
677
+
678
+ for header, value in parsed.items():
679
+ if is_chrys_managed_header_name(str(header)):
680
+ return [
681
+ render_message(
682
+ _CHAT_OPTION_HEADER_RESERVED.bind(
683
+ row=index,
684
+ header=DisplayBlock(repr(header)),
685
+ app=APP_DISPLAY_NAME,
686
+ )
687
+ )
688
+ ]
689
+ if not isinstance(value, str):
690
+ return [
691
+ render_message(
692
+ _CHAT_OPTION_HEADER_VALUE_STRING.bind(
693
+ row=index,
694
+ header=DisplayBlock(repr(header)),
695
+ )
696
+ )
697
+ ]
698
+ for charset_error in (
699
+ header_name_charset_error(str(header)),
700
+ header_value_charset_error(str(header), value),
701
+ ):
702
+ if charset_error:
703
+ return [
704
+ render_message(
705
+ _CHAT_OPTION_DETAIL.bind(
706
+ row=index,
707
+ key=DisplayBlock("extra_headers"),
708
+ detail=DisplayBlock(charset_error),
709
+ )
710
+ )
711
+ ]
712
+ return []
713
+
714
+
715
+ # ── Modal screen ─────────────────────────────────────────────────────
716
+
717
+
718
+ class ModelConfigScreen(BaseDialog[str]):
719
+ """Modal for managing model profiles with sidebar selection.
720
+
721
+ Left sidebar lists all model profiles; selecting one loads its
722
+ configuration in the right panel form.
723
+
724
+ Save writes the current form to YAML and updates the in-memory
725
+ registry. When the edited profile is the global default, its file
726
+ pointer is refreshed without changing the runtime-effective model.
727
+ The modal stays open and a success/error toast is shown.
728
+
729
+ In read-only mode, the same screen can be opened while an agent turn
730
+ is running: profile navigation stays available, but mutating controls
731
+ are hidden/disabled and Close/Escape always dismisses with ``""``.
732
+
733
+ Dismisses with ``"switched"`` when deleting the runtime-effective
734
+ profile requires a backend reload, ``"updated"`` after a Save, or
735
+ ``""`` on Close / Escape when the runtime can remain unchanged.
736
+ """
737
+
738
+ CSS_PATH = "screen.tcss"
739
+
740
+ BINDINGS: ClassVar[list] = [
741
+ localized_binding("escape", "cancel", CANCEL_BINDING, show=False, priority=True),
742
+ ]
743
+
744
+ def __init__(
745
+ self,
746
+ registry: ModelProfileRegistry,
747
+ global_default_profile_id: str = "",
748
+ *,
749
+ read_only: bool = False,
750
+ ) -> None:
751
+ self._registry = registry
752
+ self._global_default_profile_id = global_default_profile_id
753
+ self._read_only = read_only
754
+ self._selected_profile_id: str = ""
755
+ # A successful Save requires a soft restart even when the global
756
+ # default pointer is unchanged because profile fields may affect
757
+ # the runtime-effective model.
758
+ self._saved_anything = False
759
+ self._requires_runtime_reload = False
760
+ # Set while the prompt-caching reminder is pushed or showing: a second
761
+ # Save pressed before it mounts would push a second reminder.
762
+ self._prompt_cache_offer_open = False
763
+ super().__init__()
764
+
765
+ # ── Compose ──────────────────────────────────────────────────────
766
+
767
+ def compose(self) -> ComposeResult:
768
+ localizer = widget_localizer(self)
769
+ with Vertical(id="mc-container") as container:
770
+ container.border_title = Text(render_str(localizer, _CONFIGURATION_TITLE.bind()))
771
+
772
+ with Horizontal(id="mc-main"):
773
+ # Left sidebar
774
+ with Vertical(id="mc-sidebar"):
775
+ yield OptionList(id="mc-list")
776
+
777
+ # Right panel
778
+ with Vertical(id="mc-right"), VerticalScroll(id="mc-scroll"):
779
+ # Profile name
780
+ yield Label(
781
+ f"[red]*[/red] {escape(render_str(localizer, _PROFILE_NAME.bind()))}", classes="mc-label"
782
+ )
783
+ yield Input(
784
+ placeholder=render_str(localizer, _PROFILE_NAME_PLACEHOLDER.bind()),
785
+ id="mc-name",
786
+ )
787
+
788
+ with Vertical(id="mc-model-options", classes="mc-option-section") as options:
789
+ options.border_title = render_str(localizer, _MODEL_OPTIONS.bind())
790
+ # Provider
791
+ yield Label(
792
+ f"[red]*[/red] {escape(render_str(localizer, _PROVIDER.bind()))}", classes="mc-label"
793
+ )
794
+ yield Select(
795
+ _PROVIDERS,
796
+ value="openai",
797
+ id="mc-provider",
798
+ allow_blank=False,
799
+ )
800
+ yield Label(
801
+ render_str(localizer, _API_STYLE.bind()),
802
+ classes="mc-label",
803
+ id="mc-api-style-label",
804
+ )
805
+ yield Select(
806
+ [(render_str(localizer, label.bind()), value) for label, value in _API_STYLES],
807
+ value=API_STYLE_CHAT_COMPLETIONS,
808
+ id="mc-api-style",
809
+ allow_blank=False,
810
+ )
811
+
812
+ # Model name
813
+ yield Label(
814
+ f"[red]*[/red] {escape(render_str(localizer, _MODEL.bind()))}",
815
+ classes="mc-label",
816
+ id="mc-model-label",
817
+ )
818
+ yield Input(
819
+ placeholder=render_str(localizer, _MODEL_PLACEHOLDER.bind()),
820
+ id="mc-model",
821
+ )
822
+
823
+ # Max context tokens
824
+ yield Label(
825
+ f"[red]*[/red] {escape(render_str(localizer, _MAX_CONTEXT_WINDOW.bind()))}",
826
+ classes="mc-label",
827
+ )
828
+ yield Input(
829
+ value=str(DEFAULT_MAX_CONTEXT_TOKENS),
830
+ placeholder=str(DEFAULT_MAX_CONTEXT_TOKENS),
831
+ id="mc-max-tokens",
832
+ )
833
+
834
+ # Max output tokens (the model's hard per-response cap)
835
+ yield Label(
836
+ self._max_output_label("openai", API_STYLE_CHAT_COMPLETIONS),
837
+ classes="mc-label",
838
+ id="mc-max-output-label",
839
+ )
840
+ yield Input(
841
+ placeholder=render_str(
842
+ localizer,
843
+ _OUTPUT_CAP_PLACEHOLDER.bind(default=DEFAULT_MAX_OUTPUT_TOKENS),
844
+ ),
845
+ id="mc-max-output-tokens",
846
+ )
847
+
848
+ # Base URL — placeholder is updated per-provider in
849
+ # ``_update_provider_labels``; the initial value matches
850
+ # the default provider above (``openai``).
851
+ yield Label(render_str(localizer, _BASE_URL.bind()), classes="mc-label", id="mc-url-label")
852
+ yield Input(
853
+ placeholder=_PROVIDER_DEFAULT_BASE_URLS["openai"],
854
+ id="mc-base-url",
855
+ )
856
+
857
+ # API Key
858
+ yield Label(render_str(localizer, _API_KEY.bind()), classes="mc-label", id="mc-key-label")
859
+ yield Input(
860
+ placeholder="sk-...",
861
+ password=True,
862
+ id="mc-api-key",
863
+ )
864
+
865
+ yield Checkbox(
866
+ render_str(localizer, _STREAMING.bind()),
867
+ value=True,
868
+ id="mc-stream",
869
+ classes="mc-checkbox",
870
+ )
871
+ yield Checkbox(
872
+ render_str(localizer, _VISION.bind()),
873
+ value=False,
874
+ id="mc-vision",
875
+ classes="mc-checkbox",
876
+ )
877
+
878
+ with Vertical(id="mc-connection-options", classes="mc-option-section") as options:
879
+ options.border_title = render_str(localizer, _CONNECTION_OPTIONS.bind())
880
+ yield Checkbox(
881
+ render_str(localizer, _SKIP_TLS_VERIFICATION.bind()),
882
+ value=False,
883
+ id="mc-skip-tls",
884
+ classes="mc-checkbox",
885
+ )
886
+ tls_hint = Label(
887
+ render_str(localizer, _INSECURE_TLS.bind()),
888
+ id="mc-skip-tls-hint",
889
+ classes="mc-hint mc-tls-hint",
890
+ )
891
+ tls_hint.display = False
892
+ yield tls_hint
893
+ yield Checkbox(
894
+ render_str(localizer, _BYPASS_PROXY.bind()),
895
+ value=False,
896
+ id="mc-bypass-proxy",
897
+ classes="mc-checkbox",
898
+ )
899
+
900
+ with Vertical(id="mc-http-options", classes="mc-option-section") as options:
901
+ options.border_title = render_str(localizer, _HTTP_OPTIONS.bind())
902
+ # HTTP Connect Timeout
903
+ yield Label(render_str(localizer, _HTTP_CONNECT_TIMEOUT.bind()), classes="mc-label")
904
+ yield Input(
905
+ value="10.0",
906
+ placeholder="10.0",
907
+ id="mc-connect-timeout",
908
+ )
909
+
910
+ # HTTP Read Timeout
911
+ yield Label(render_str(localizer, _HTTP_READ_TIMEOUT.bind()), classes="mc-label")
912
+ yield Input(
913
+ value="300.0",
914
+ placeholder="300.0",
915
+ id="mc-read-timeout",
916
+ )
917
+
918
+ # HTTP Max Retries
919
+ yield Label(render_str(localizer, _HTTP_MAX_RETRIES.bind()), classes="mc-label")
920
+ yield Input(
921
+ value="2",
922
+ placeholder="2",
923
+ id="mc-max-retries",
924
+ )
925
+
926
+ with Vertical(id="mc-extra-options", classes="mc-option-section") as options:
927
+ options.border_title = render_str(localizer, _EXTRA_OPTIONS.bind())
928
+ # HTTP Extra Headers — key-value list
929
+ yield Label(render_str(localizer, _HTTP_EXTRA_HEADERS.bind()), classes="mc-label")
930
+ with Vertical(classes="mc-kv-list", id="mc-headers-list"):
931
+ yield self._compose_kv_row("", "", "h", read_only=self._read_only)
932
+ yield self._compose_kv_add_row("h", read_only=self._read_only)
933
+
934
+ # Chat Options — key-value list
935
+ yield Label(render_str(localizer, _CHAT_OPTIONS.bind()), classes="mc-label")
936
+ with Vertical(classes="mc-kv-list", id="mc-options-list"):
937
+ yield self._compose_kv_row("", "", "o", read_only=self._read_only)
938
+ yield self._compose_kv_add_row("o", read_only=self._read_only)
939
+
940
+ # Footer buttons
941
+ with Vertical(id="mc-footer") as footer:
942
+ if self._read_only:
943
+ footer.add_class("-read-only")
944
+ with Horizontal(id="mc-buttons"):
945
+ yield Button(render_str(localizer, _NEW.bind()), variant="primary", id="mc-new", flat=True)
946
+ yield Button(render_str(localizer, _CLONE.bind()), variant="primary", id="mc-clone", flat=True)
947
+ yield Button(render_str(localizer, _DELETE.bind()), variant="error", id="mc-delete", flat=True)
948
+ yield Static(id="mc-buttons-spacer")
949
+ yield Button(render_str(localizer, _SAVE.bind()), variant="primary", id="mc-save", flat=True)
950
+ yield Button(render_str(localizer, _CLOSE.bind()), variant="warning", id="mc-cancel", flat=True)
951
+ notice = Static(Text(render_str(localizer, _READ_ONLY_NOTICE.bind())), id="mc-read-only-notice")
952
+ notice.display = self._read_only
953
+ yield notice
954
+
955
+ # ── Mount ────────────────────────────────────────────────────────
956
+
957
+ async def on_mount(self) -> None:
958
+ profiles = self._listed_profiles()
959
+ if not profiles:
960
+ if self._read_only:
961
+ self._set_form_enabled(False)
962
+ self._apply_read_only_state()
963
+ return
964
+ # Auto-create a default profile so the user can start editing immediately
965
+ await self._create_new_profile()
966
+ return
967
+
968
+ self._populate_sidebar()
969
+ # Select the global default profile, or the first available profile.
970
+ target_id = self._global_default_profile_id
971
+ if not target_id or self._registry.get(target_id) is None:
972
+ target_id = profiles[0].id
973
+ ol = self.query_one("#mc-list", OptionList)
974
+ with contextlib.suppress(OptionDoesNotExist):
975
+ ol.highlighted = ol.get_option_index(target_id)
976
+ self._load_profile(target_id)
977
+ self._apply_read_only_state()
978
+
979
+ def _listed_profiles(self) -> list[ModelProfile]:
980
+ """The registry's profiles in the order the sidebar lists them: as a person reads their names."""
981
+ return sorted(self._registry.list_profiles(), key=lambda profile: reading_order(profile.name))
982
+
983
+ def _populate_sidebar(self) -> None:
984
+ ol = self.query_one("#mc-list", OptionList)
985
+ ol.clear_options()
986
+ profiles = self._listed_profiles()
987
+ for i, p in enumerate(profiles):
988
+ if i > 0:
989
+ ol.add_option(None) # Divider
990
+ content = Content.assemble((p.name, "bold"))
991
+ ol.add_option(Option(content, id=p.id))
992
+
993
+ def _set_form_enabled(self, enabled: bool) -> None:
994
+ """Enable or disable the right-panel form and Save/Delete buttons."""
995
+ interactive = enabled and not self._read_only
996
+ self.query_one("#mc-save", Button).disabled = not interactive
997
+ self._update_delete_button()
998
+ self._apply_read_only_state()
999
+
1000
+ def _update_delete_button(self) -> None:
1001
+ """Disable Delete when there is only one profile (or none selected)."""
1002
+ count = len(self._registry.list_profiles())
1003
+ button = self.query_one("#mc-delete", Button)
1004
+ button.disabled = self._read_only or count <= 1 or not self._selected_profile_id
1005
+ if self._read_only:
1006
+ button.display = False
1007
+
1008
+ def _apply_read_only_state(self) -> None:
1009
+ """Make the mounted form view-only while keeping sidebar navigation usable."""
1010
+ if not self._read_only or not self.is_attached:
1011
+ return
1012
+ try:
1013
+ self.query_one("#mc-footer", Vertical).add_class("-read-only")
1014
+ self.query_one("#mc-read-only-notice", Static).display = True
1015
+ except NoMatches:
1016
+ return
1017
+ for input_widget in self.query(Input):
1018
+ input_widget.disabled = True
1019
+ for select in self.query(Select):
1020
+ select.disabled = True
1021
+ for checkbox in self.query(Checkbox):
1022
+ checkbox.disabled = True
1023
+ for button in self.query(Button):
1024
+ if button.id == "mc-cancel":
1025
+ button.disabled = False
1026
+ button.display = True
1027
+ else:
1028
+ button.disabled = True
1029
+ button.display = False
1030
+
1031
+ # ── Key-value list helpers ───────────────────────────────────────
1032
+
1033
+ def _compose_kv_row(
1034
+ self,
1035
+ key: str,
1036
+ val: str,
1037
+ prefix: str,
1038
+ *,
1039
+ read_only: bool = False,
1040
+ ) -> Horizontal:
1041
+ uid = _next_uid()
1042
+ key_placeholder, value_placeholder = self._kv_placeholders(prefix)
1043
+ key_input = Input(value=key, placeholder=key_placeholder, classes="mc-kv-field mc-kv-key-input")
1044
+ value_input = Input(value=val, placeholder=value_placeholder, classes="mc-kv-field mc-kv-value-input")
1045
+ remove_button = Button("✕", classes="mc-kv-remove-btn", id=f"mc-{prefix}del-{uid}")
1046
+ if read_only:
1047
+ # Seed read-only state here so mounted rows never have a pre-refresh editable window.
1048
+ key_input.disabled = True
1049
+ value_input.disabled = True
1050
+ remove_button.disabled = True
1051
+ remove_button.display = False
1052
+ return Horizontal(
1053
+ key_input,
1054
+ value_input,
1055
+ remove_button,
1056
+ classes="mc-kv-item-row",
1057
+ )
1058
+
1059
+ def _compose_kv_add_row(self, prefix: str, *, read_only: bool = False) -> Horizontal:
1060
+ add_button = ConfigAddButton(
1061
+ self._render_toast(_ADD.bind()),
1062
+ compact=True,
1063
+ classes="mc-kv-add-btn",
1064
+ id=f"mc-{prefix}add",
1065
+ )
1066
+ if read_only:
1067
+ add_button.disabled = True
1068
+ add_button.display = False
1069
+ return Horizontal(
1070
+ add_button,
1071
+ classes="mc-kv-add-row",
1072
+ id=f"mc-{prefix}add-row",
1073
+ )
1074
+
1075
+ def _populate_kv_list(self, container_id: str, items: dict[str, str], prefix: str) -> None:
1076
+ """Clear and repopulate a key-value list container."""
1077
+ container = self.query_one(f"#{container_id}", Vertical)
1078
+ # Remove all existing item rows (keep the add row)
1079
+ for row in list(container.query(".mc-kv-item-row")):
1080
+ row.remove()
1081
+ # Add rows before the add-row
1082
+ add_row_id = f"mc-{prefix}add-row"
1083
+ add_row = self.query_one(f"#{add_row_id}", Horizontal)
1084
+ for key, val in items.items():
1085
+ container.mount(self._compose_kv_row(key, val, prefix, read_only=self._read_only), before=add_row)
1086
+ if not items:
1087
+ container.mount(self._compose_kv_row("", "", prefix, read_only=self._read_only), before=add_row)
1088
+ self._apply_read_only_state()
1089
+ self.call_after_refresh(self._apply_read_only_state)
1090
+
1091
+ def _read_kv_list(self, container_id: str) -> dict[str, str]:
1092
+ """Read all key-value pairs from a list container."""
1093
+ result: dict[str, str] = {}
1094
+ container = self.query_one(f"#{container_id}", Vertical)
1095
+ for row in container.query(".mc-kv-item-row"):
1096
+ key = row.query_one(".mc-kv-key-input", Input).value.strip()
1097
+ value = row.query_one(".mc-kv-value-input", Input).value.strip()
1098
+ if key:
1099
+ result[key] = value
1100
+ return result
1101
+
1102
+ def _validate_kv_list(
1103
+ self,
1104
+ container_id: str,
1105
+ label_definition: MessageDef,
1106
+ *,
1107
+ http_headers: bool = False,
1108
+ ) -> list[str]:
1109
+ """Validate that partially-filled key-value rows are not saved silently.
1110
+
1111
+ ``http_headers`` marks rows destined for HTTP headers: managed
1112
+ header names are rejected, and names/values must survive ASCII
1113
+ header encoding (``{{ENV_VAR}}`` templates are ASCII and pass;
1114
+ their resolved values are re-checked when the client is built).
1115
+ """
1116
+ label = self._render_toast(label_definition.bind())
1117
+ errors: list[str] = []
1118
+ seen: set[str] = set()
1119
+ container = self.query_one(f"#{container_id}", Vertical)
1120
+ for index, row in enumerate(container.query(".mc-kv-item-row"), start=1):
1121
+ key = row.query_one(".mc-kv-key-input", Input).value.strip()
1122
+ value = row.query_one(".mc-kv-value-input", Input).value.strip()
1123
+ if not key and not value:
1124
+ continue
1125
+ if key and not value:
1126
+ errors.append(
1127
+ self._render_toast(
1128
+ _KV_VALUE_REQUIRED.bind(
1129
+ label=label,
1130
+ row=index,
1131
+ key=DisplayBlock(key),
1132
+ )
1133
+ )
1134
+ )
1135
+ elif value and not key:
1136
+ errors.append(self._render_toast(_KV_KEY_REQUIRED.bind(label=label, row=index)))
1137
+ if key and http_headers and is_chrys_managed_header_name(key):
1138
+ errors.append(
1139
+ self._render_toast(
1140
+ _KV_HEADER_RESERVED.bind(
1141
+ label=label,
1142
+ row=index,
1143
+ key=DisplayBlock(key),
1144
+ app=APP_DISPLAY_NAME,
1145
+ )
1146
+ )
1147
+ )
1148
+ if key and http_headers:
1149
+ errors.extend(
1150
+ self._render_toast(
1151
+ _KV_DETAIL.bind(
1152
+ label=label,
1153
+ row=index,
1154
+ detail=DisplayBlock(charset_error),
1155
+ )
1156
+ )
1157
+ for charset_error in (
1158
+ header_name_charset_error(key),
1159
+ header_value_charset_error(key, value),
1160
+ )
1161
+ if charset_error
1162
+ )
1163
+ if key in seen:
1164
+ errors.append(
1165
+ self._render_toast(
1166
+ _KV_DUPLICATE_KEY.bind(
1167
+ label=label,
1168
+ row=index,
1169
+ key=DisplayBlock(key),
1170
+ )
1171
+ )
1172
+ )
1173
+ if key:
1174
+ seen.add(key)
1175
+ return errors
1176
+
1177
+ def _validate_required_positive_int(
1178
+ self,
1179
+ field_id: str,
1180
+ label_definition: MessageDef,
1181
+ *,
1182
+ example: int,
1183
+ ) -> list[str]:
1184
+ """Required positive-integer rule shared by the token-count fields."""
1185
+ label = self._render_toast(label_definition.bind())
1186
+ value = self.query_one(field_id, Input).value.strip()
1187
+ if not value:
1188
+ return [self._render_toast(_FIELD_REQUIRED_EXAMPLE.bind(label=label, example=example))]
1189
+ try:
1190
+ parsed = int(value)
1191
+ except ValueError:
1192
+ return [self._render_toast(_FIELD_VALID_INTEGER.bind(label=label))]
1193
+ if parsed <= 0:
1194
+ return [self._render_toast(_FIELD_POSITIVE_INTEGER.bind(label=label))]
1195
+ return []
1196
+
1197
+ def _validate_chat_option_values(self) -> list[str]:
1198
+ """Validate common ChatOptions values before saving."""
1199
+ errors: list[str] = []
1200
+ container = self.query_one("#mc-options-list", Vertical)
1201
+ for index, row in enumerate(container.query(".mc-kv-item-row"), start=1):
1202
+ key = row.query_one(".mc-kv-key-input", Input).value.strip()
1203
+ value = row.query_one(".mc-kv-value-input", Input).value.strip()
1204
+ if not key or not value:
1205
+ continue
1206
+
1207
+ parsed = _parse_chat_option_value_for_validation(value)
1208
+
1209
+ if key in OUTPUT_CAP_OPTION_ALIASES:
1210
+ # A profile-level output cap (any provider spelling) would
1211
+ # fight the dedicated field: internal calls (e.g. LAST_WORDS
1212
+ # notes) size max_tokens per request and clamp to Max Output
1213
+ # Tokens, so a chat-options copy is at best redundant and at
1214
+ # worst overrides the clamp.
1215
+ errors.append(
1216
+ self._render_toast(
1217
+ _CHAT_OPTION_OUTPUT_CAP.bind(
1218
+ row=index,
1219
+ key=DisplayBlock(repr(key)),
1220
+ )
1221
+ )
1222
+ )
1223
+ continue
1224
+ if key in PROTECTED_TOP_LEVEL_CHAT_OPTION_KEYS:
1225
+ errors.append(
1226
+ self._render_toast(
1227
+ _CHAT_OPTION_PROTECTED.bind(
1228
+ row=index,
1229
+ key=DisplayBlock(repr(key)),
1230
+ )
1231
+ )
1232
+ )
1233
+ continue
1234
+ if key == "extra_body" and isinstance(parsed, dict):
1235
+ protected_nested = sorted(PROTECTED_EXTRA_BODY_CHAT_OPTION_KEYS & parsed.keys())
1236
+ if protected_nested:
1237
+ errors.append(
1238
+ self._render_toast(
1239
+ _CHAT_OPTION_NESTED_PROTECTED.bind(
1240
+ row=index,
1241
+ keys=DisplayBlock(", ".join(protected_nested)),
1242
+ )
1243
+ )
1244
+ )
1245
+ continue
1246
+ if key in _JSON_OBJECT_CHAT_OPTION_KEYS:
1247
+ if key == "logit_bias":
1248
+ errors.extend(_validate_chat_option_logit_bias(index, parsed, render_message=self._render_toast))
1249
+ elif key == "extra_headers":
1250
+ errors.extend(_validate_chat_option_extra_headers(index, parsed, render_message=self._render_toast))
1251
+ else:
1252
+ errors.extend(_validate_chat_option_object(index, key, parsed, render_message=self._render_toast))
1253
+ elif key in _FLOAT_CHAT_OPTION_RULES:
1254
+ errors.extend(_validate_chat_option_float(index, key, parsed, render_message=self._render_toast))
1255
+ elif key in _INT_CHAT_OPTION_RULES:
1256
+ errors.extend(_validate_chat_option_int(index, key, parsed, render_message=self._render_toast))
1257
+ elif key in _BOOL_CHAT_OPTION_KEYS:
1258
+ errors.extend(_validate_chat_option_bool(index, key, parsed, render_message=self._render_toast))
1259
+ elif key in _STRING_CHAT_OPTION_KEYS:
1260
+ errors.extend(
1261
+ _validate_chat_option_string(
1262
+ index,
1263
+ key,
1264
+ parsed,
1265
+ value,
1266
+ render_message=self._render_toast,
1267
+ )
1268
+ )
1269
+ elif key == "stop":
1270
+ errors.extend(_validate_chat_option_stop(index, parsed, value, render_message=self._render_toast))
1271
+ return errors
1272
+
1273
+ def _kv_placeholders(self, prefix: str) -> tuple[str, str]:
1274
+ if prefix == "h":
1275
+ return (
1276
+ self._render_toast(_HEADER_NAME_PLACEHOLDER.bind()),
1277
+ self._render_toast(_HEADER_VALUE_PLACEHOLDER.bind()),
1278
+ )
1279
+ return (
1280
+ self._render_toast(_OPTION_NAME_PLACEHOLDER.bind()),
1281
+ self._render_toast(_OPTION_VALUE_PLACEHOLDER.bind()),
1282
+ )
1283
+
1284
+ async def _add_kv_item(self, container_id: str, prefix: str) -> None:
1285
+ """Append another editable key-value row."""
1286
+ if self._read_only:
1287
+ return
1288
+ try:
1289
+ container = self.query_one(f"#{container_id}", Vertical)
1290
+ add_row = self.query_one(f"#mc-{prefix}add-row", Horizontal)
1291
+ except Exception:
1292
+ return
1293
+
1294
+ await container.mount(self._compose_kv_row("", "", prefix), before=add_row)
1295
+ container.refresh(layout=True)
1296
+
1297
+ # ── Profile loading ──────────────────────────────────────────────
1298
+
1299
+ def _load_profile(self, profile_id: str) -> None:
1300
+ """Populate the form fields from the given profile."""
1301
+ profile = self._registry.get(profile_id)
1302
+ if profile is None:
1303
+ return
1304
+ self._selected_profile_id = profile_id
1305
+ self._set_form_enabled(True)
1306
+
1307
+ self.query_one("#mc-name", Input).value = profile.name
1308
+ self.query_one("#mc-provider", Select).value = profile.provider
1309
+ self.query_one("#mc-api-style", Select).value = (
1310
+ profile.api_style if profile.provider in ("openai", "deepseek-openai") else API_STYLE_CHAT_COMPLETIONS
1311
+ )
1312
+ self.query_one("#mc-model", Input).value = profile.model_id
1313
+ self.query_one("#mc-max-tokens", Input).value = str(profile.max_context_tokens)
1314
+ self.query_one("#mc-max-output-tokens", Input).value = str(
1315
+ profile.max_output_tokens if profile.max_output_tokens > 0 else DEFAULT_MAX_OUTPUT_TOKENS
1316
+ )
1317
+ self.query_one("#mc-base-url", Input).value = profile.base_url
1318
+ self.query_one("#mc-api-key", Input).value = profile.api_key
1319
+ self.query_one("#mc-connect-timeout", Input).value = str(profile.http_connect_timeout)
1320
+ self.query_one("#mc-read-timeout", Input).value = str(profile.http_read_timeout)
1321
+ self.query_one("#mc-max-retries", Input).value = str(profile.http_max_retries)
1322
+ skip_tls = not profile.verify_ssl
1323
+ self.query_one("#mc-skip-tls", Checkbox).value = skip_tls
1324
+ self.query_one("#mc-skip-tls-hint", Label).display = skip_tls
1325
+ self.query_one("#mc-bypass-proxy", Checkbox).value = profile.bypass_proxy
1326
+ self.query_one("#mc-stream", Checkbox).value = profile.stream
1327
+ self.query_one("#mc-vision", Checkbox).value = profile.vision
1328
+
1329
+ # Populate key-value lists
1330
+ headers = _parse_json_dict(profile.http_headers, parse_values=False)
1331
+ self._populate_kv_list("mc-headers-list", headers, "h")
1332
+
1333
+ options = _parse_json_dict(profile.chat_options)
1334
+ self._populate_kv_list("mc-options-list", options, "o")
1335
+
1336
+ self._update_provider_labels(profile.provider)
1337
+ self._apply_read_only_state()
1338
+ self.call_after_refresh(self._apply_read_only_state)
1339
+
1340
+ # ── Provider-dependent labels ────────────────────────────────────
1341
+
1342
+ def _update_provider_labels(self, provider: str) -> None:
1343
+ """Update field labels and the Base URL placeholder for the selected provider."""
1344
+ prefix = _PROVIDER_LABELS.get(provider, provider.title())
1345
+ self.query_one("#mc-model-label", Label).update(
1346
+ f"[red]*[/red] {escape(self._render_toast(_PROVIDER_MODEL.bind(provider=prefix)))}"
1347
+ )
1348
+ self.query_one("#mc-url-label", Label).update(self._render_toast(_PROVIDER_BASE_URL.bind(provider=prefix)))
1349
+ self.query_one("#mc-key-label", Label).update(self._render_toast(_PROVIDER_API_KEY.bind(provider=prefix)))
1350
+
1351
+ default_base_url = _PROVIDER_DEFAULT_BASE_URLS.get(provider, "")
1352
+ self.query_one("#mc-base-url", Input).placeholder = default_base_url or self._render_toast(
1353
+ _LEAVE_BLANK_FOR_DEFAULT.bind()
1354
+ )
1355
+ api_style_label = self.query_one("#mc-api-style-label", Label)
1356
+ api_style_select = self.query_one("#mc-api-style", Select)
1357
+ supports_api_style = provider in ("openai", "deepseek-openai")
1358
+ api_style_label.display = supports_api_style
1359
+ api_style_select.display = supports_api_style
1360
+ if not supports_api_style:
1361
+ api_style_select.value = API_STYLE_CHAT_COMPLETIONS
1362
+ self._refresh_max_output_label(provider, str(api_style_select.value))
1363
+
1364
+ def _refresh_max_output_label(self, provider: str, api_style: str) -> None:
1365
+ self.query_one("#mc-max-output-label", Label).update(self._max_output_label(provider, api_style))
1366
+
1367
+ def _max_output_label(self, provider: str, api_style: str) -> Text:
1368
+ param = _token_limit_param_name(provider, api_style)
1369
+ rendered = self._render_toast(_MAX_OUTPUT_TOKENS.bind(param=param))
1370
+ label = Text(rendered)
1371
+ start = rendered.find(param)
1372
+ if start >= 0:
1373
+ label.stylize("dim", start, start + len(param))
1374
+ return label
1375
+
1376
+ # ── Events ───────────────────────────────────────────────────────
1377
+
1378
+ @on(Select.Changed, "#mc-provider")
1379
+ def _on_provider_changed(self, event: Select.Changed) -> None:
1380
+ self._update_provider_labels(str(event.value))
1381
+
1382
+ @on(Select.Changed, "#mc-api-style")
1383
+ def _on_api_style_changed(self, _event: Select.Changed) -> None:
1384
+ self._refresh_max_output_label(
1385
+ str(self.query_one("#mc-provider", Select).value),
1386
+ str(self.query_one("#mc-api-style", Select).value),
1387
+ )
1388
+
1389
+ @on(Checkbox.Changed, "#mc-skip-tls")
1390
+ def _on_skip_tls_changed(self, event: Checkbox.Changed) -> None:
1391
+ with contextlib.suppress(Exception):
1392
+ self.query_one("#mc-skip-tls-hint", Label).display = bool(event.value)
1393
+
1394
+ @on(OptionList.OptionSelected, "#mc-list")
1395
+ def _on_profile_selected(self, event: OptionList.OptionSelected) -> None:
1396
+ if event.option.id and event.option.id != self._selected_profile_id:
1397
+ self._load_profile(event.option.id)
1398
+
1399
+ @on(Button.Pressed, "#mc-hadd")
1400
+ async def _on_header_add(self, _event: Button.Pressed) -> None:
1401
+ await self._add_kv_item("mc-headers-list", "h")
1402
+
1403
+ @on(Button.Pressed, "#mc-oadd")
1404
+ async def _on_option_add(self, _event: Button.Pressed) -> None:
1405
+ await self._add_kv_item("mc-options-list", "o")
1406
+
1407
+ @on(Button.Pressed)
1408
+ def _on_kv_remove(self, event: Button.Pressed) -> None:
1409
+ """Handle removal of key-value rows (header or option)."""
1410
+ if self._read_only:
1411
+ return
1412
+ btn_id = event.button.id or ""
1413
+ if btn_id.startswith(("mc-hdel-", "mc-odel-")):
1414
+ row = event.button.parent
1415
+ if isinstance(row, Widget) and isinstance(row.parent, Vertical):
1416
+ container = row.parent
1417
+ was_last_row = len(list(container.query(".mc-kv-item-row"))) <= 1
1418
+ if was_last_row:
1419
+ row.query_one(".mc-kv-key-input", Input).value = ""
1420
+ row.query_one(".mc-kv-value-input", Input).value = ""
1421
+ row.refresh(layout=True)
1422
+ else:
1423
+ row.remove()
1424
+
1425
+ async def _create_new_profile(self) -> None:
1426
+ """Create a new blank profile, add to sidebar, and select it.
1427
+
1428
+ Names follow the pattern "Profile N" (starting at N=1) and
1429
+ increment until an unused name is found — so the first auto-
1430
+ seeded profile is "Profile 1", the next "Profile 2", etc.
1431
+ """
1432
+ if self._read_only:
1433
+ return
1434
+ from chrys.service.profiles.models.schema import ModelProfile
1435
+ from chrys.service.profiles.models.serializer import save_profile
1436
+
1437
+ # Case-insensitive collision check: "Profile 1" / "profile 1" are
1438
+ # treated as duplicates to match the uniqueness rule used elsewhere.
1439
+ existing_names = {p.name.casefold() for p in self._registry.list_profiles()}
1440
+ base = "Profile"
1441
+ counter = 1
1442
+ name = f"{base} {counter}"
1443
+ while name.casefold() in existing_names:
1444
+ counter += 1
1445
+ name = f"{base} {counter}"
1446
+
1447
+ profile_id = self._next_profile_id()
1448
+ profile = ModelProfile(id=profile_id, name=name)
1449
+ await asyncio.to_thread(save_profile, profile)
1450
+ self._registry.register(profile)
1451
+
1452
+ self._populate_sidebar()
1453
+ ol = self.query_one("#mc-list", OptionList)
1454
+ with contextlib.suppress(OptionDoesNotExist):
1455
+ ol.highlighted = ol.get_option_index(profile_id)
1456
+ self._load_profile(profile_id)
1457
+ self._update_delete_button()
1458
+
1459
+ def _next_profile_id(self) -> str:
1460
+ """Generate a fresh model profile id."""
1461
+ while True:
1462
+ profile_id = uuid.uuid4().hex[:12]
1463
+ if self._registry.get(profile_id) is None:
1464
+ return profile_id
1465
+
1466
+ @staticmethod
1467
+ def _clone_name_parts(name: str) -> tuple[str, int] | None:
1468
+ """Return ``(root, suffix_index)`` for generated clone names."""
1469
+ match = re.fullmatch(r"(.+?) copy(?: (\d+))?", name, flags=re.IGNORECASE)
1470
+ if match is None:
1471
+ return None
1472
+ suffix = match.group(2)
1473
+ return match.group(1), int(suffix) if suffix is not None else 1
1474
+
1475
+ def _clone_name_indices(self, root_name: str) -> list[int]:
1476
+ """Return existing generated clone suffixes for the given root."""
1477
+ indices: list[int] = []
1478
+ for profile in self._registry.list_profiles():
1479
+ parts = self._clone_name_parts(profile.name)
1480
+ if parts is not None and parts[0].casefold() == root_name.casefold():
1481
+ indices.append(parts[1])
1482
+ return indices
1483
+
1484
+ def _next_clone_name(self, source_name: str) -> str:
1485
+ """Generate a unique display name for a cloned model profile."""
1486
+ existing_names = {p.name.casefold() for p in self._registry.list_profiles()}
1487
+ source_parts = self._clone_name_parts(source_name)
1488
+ if source_parts is None:
1489
+ root_name = source_name
1490
+ counter = 1
1491
+ else:
1492
+ root_name, source_index = source_parts
1493
+ counter = max([source_index, *self._clone_name_indices(root_name)]) + 1
1494
+ base = f"{root_name} Copy"
1495
+ name = base if counter == 1 else f"{base} {counter}"
1496
+ while name.casefold() in existing_names:
1497
+ counter += 1
1498
+ name = f"{base} {counter}"
1499
+ return name
1500
+
1501
+ @on(Button.Pressed, "#mc-new")
1502
+ async def _on_new(self, _event: Button.Pressed) -> None:
1503
+ """Create a new blank profile and select it."""
1504
+ if self._read_only:
1505
+ return
1506
+ await self._create_new_profile()
1507
+
1508
+ @on(Button.Pressed, "#mc-clone")
1509
+ async def _on_clone(self, _event: Button.Pressed) -> None:
1510
+ """Create a saved copy of the selected profile and select it."""
1511
+ if self._read_only:
1512
+ return
1513
+ source = self._registry.get(self._selected_profile_id)
1514
+ if source is None:
1515
+ return
1516
+
1517
+ from chrys.service.profiles.models.serializer import save_profile
1518
+
1519
+ duplicate = copy.deepcopy(source)
1520
+ duplicate.id = self._next_profile_id()
1521
+ duplicate.name = self._next_clone_name(source.name)
1522
+ await asyncio.to_thread(save_profile, duplicate)
1523
+ self._registry.register(duplicate)
1524
+
1525
+ self._populate_sidebar()
1526
+ ol = self.query_one("#mc-list", OptionList)
1527
+ with contextlib.suppress(OptionDoesNotExist):
1528
+ ol.highlighted = ol.get_option_index(duplicate.id)
1529
+ self._load_profile(duplicate.id)
1530
+ self._update_delete_button()
1531
+ self._notify_unsupported_options(duplicate)
1532
+
1533
+ @on(Button.Pressed, "#mc-delete")
1534
+ def _on_delete(self, _event: Button.Pressed) -> None:
1535
+ """Delete the selected profile after user confirmation."""
1536
+ if self._read_only:
1537
+ return
1538
+ profile_id = self._selected_profile_id
1539
+ if not profile_id:
1540
+ return
1541
+ profile = self._registry.get(profile_id)
1542
+ display = profile.name if profile else profile_id
1543
+
1544
+ from chrys.app.tui.screens.dialogs.confirm import ConfirmDialog
1545
+
1546
+ dialog = ConfirmDialog(
1547
+ title=_DELETE_PROFILE_TITLE.bind(),
1548
+ message=_DELETE_PROFILE_MESSAGE.bind(display=display),
1549
+ confirm_label=_DELETE.bind(),
1550
+ confirm_variant="error",
1551
+ locale_controller=widget_locale_controller(self),
1552
+ )
1553
+
1554
+ async def _on_confirmed(confirmed: bool | None) -> None:
1555
+ if not confirmed:
1556
+ return
1557
+ try:
1558
+ await self._do_delete(profile_id)
1559
+ except Exception as exc:
1560
+ self.notify(
1561
+ str(exc),
1562
+ title=self._render_toast(_MODEL_SETTINGS_TITLE.bind()),
1563
+ severity="error",
1564
+ timeout=5,
1565
+ markup=False,
1566
+ )
1567
+
1568
+ self.app.push_screen(dialog, callback=_on_confirmed)
1569
+
1570
+ async def _do_delete(self, profile_id: str) -> None:
1571
+ """Actually delete the profile after confirmation.
1572
+
1573
+ Deleting the global default promotes, in the user settings document,
1574
+ the first remaining profile as the sidebar lists them. Deleting the runtime-effective profile also
1575
+ resynchronizes the process pointer to a selectable profile — the
1576
+ file default when it resolves, else the first selectable one —
1577
+ and requests a backend reload. If no profiles remain, a fresh
1578
+ editable profile is created before either pointer is reconciled.
1579
+ """
1580
+ if self._read_only:
1581
+ return
1582
+ from chrys.service.profiles.models.env_bridge import get_active_profile_id, get_global_default_profile_id
1583
+ from chrys.service.profiles.models.resolver import resolve_profile_selector, resolve_selectable_profile
1584
+ from chrys.service.profiles.models.schema import is_model_profile_selectable
1585
+ from chrys.service.profiles.models.serializer import delete_profile
1586
+
1587
+ was_global_default = profile_id == self._global_default_profile_id
1588
+ # The process pointer may hold a profile NAME (id-or-unique-name
1589
+ # selection is supported); resolve it against the still-complete
1590
+ # registry before deleting, or a name pointer at the deleted profile
1591
+ # would silently keep the dead executor running.
1592
+ active_profile = resolve_profile_selector(self._registry, get_active_profile_id())
1593
+ was_runtime_effective = active_profile is not None and active_profile.id == profile_id
1594
+
1595
+ await asyncio.to_thread(delete_profile, profile_id)
1596
+ self._registry.remove(profile_id)
1597
+
1598
+ profiles = self._listed_profiles()
1599
+ seeded_default = not profiles
1600
+ if seeded_default:
1601
+ await self._create_new_profile()
1602
+ profiles = self._listed_profiles()
1603
+
1604
+ if was_global_default:
1605
+ # Prefer a selectable replacement — the listed order can put a
1606
+ # hollow auto-seeded profile first, and promoting it would point
1607
+ # both pointers at an unusable model while real ones exist.
1608
+ new_global_default = next(
1609
+ (candidate for candidate in profiles if is_model_profile_selectable(candidate)),
1610
+ profiles[0],
1611
+ )
1612
+ await self._persist_active_profile(new_global_default)
1613
+ self._global_default_profile_id = new_global_default.id
1614
+
1615
+ if was_runtime_effective:
1616
+ # The file default may itself be dangling or hollow (deleting a
1617
+ # non-default profile never repairs the file pointer); adopting
1618
+ # it verbatim would strand the session on the placeholder while
1619
+ # a selectable profile exists. Resolve it first and fall back
1620
+ # the same way global-default promotion does.
1621
+ global_default_profile_id = await asyncio.to_thread(get_global_default_profile_id)
1622
+ replacement = resolve_selectable_profile(self._registry, global_default_profile_id)
1623
+ if replacement is None:
1624
+ replacement = next(
1625
+ (candidate for candidate in profiles if is_model_profile_selectable(candidate)),
1626
+ None,
1627
+ )
1628
+ if replacement is not None:
1629
+ set_model_pointer(replacement.id, origin=SettingOrigin(layer=Source.PROCESS_RUNTIME))
1630
+ else:
1631
+ set_model_pointer(None, origin=SettingOrigin(layer=Source.PROCESS_RUNTIME))
1632
+ self._requires_runtime_reload = True
1633
+
1634
+ if seeded_default:
1635
+ return
1636
+
1637
+ self._populate_sidebar()
1638
+ first = profiles[0].id
1639
+ ol = self.query_one("#mc-list", OptionList)
1640
+ with contextlib.suppress(OptionDoesNotExist):
1641
+ ol.highlighted = ol.get_option_index(first)
1642
+ self._load_profile(first)
1643
+
1644
+ async def _persist_active_profile(self, profile: ModelProfile | None) -> None:
1645
+ """Write the global default profile pointer to the user settings document only.
1646
+
1647
+ ``None`` removes ``model.profile.active`` so resolvers fall back to
1648
+ the built-in default profile.
1649
+ """
1650
+ if self._read_only:
1651
+ return
1652
+ from chrys.service.profiles.models.env_bridge import set_global_default_profile_id
1653
+
1654
+ await asyncio.to_thread(set_global_default_profile_id, profile.id if profile is not None else "")
1655
+
1656
+ @on(Button.Pressed, "#mc-save")
1657
+ async def _on_save(self, _event: Button.Pressed) -> None:
1658
+ """Save the form without changing the runtime-effective pointer."""
1659
+ if self._read_only or self._prompt_cache_offer_open:
1660
+ return
1661
+ errors = self._validate()
1662
+ if errors:
1663
+ self.notify(
1664
+ "\n".join(errors),
1665
+ title=self._render_toast(_VALIDATION_ERROR_TITLE.bind()),
1666
+ severity="error",
1667
+ timeout=5,
1668
+ markup=False,
1669
+ )
1670
+ return
1671
+ if self._should_offer_prompt_cache():
1672
+ self._offer_prompt_cache()
1673
+ return
1674
+ await self._save_and_report()
1675
+
1676
+ async def _save_and_report(self) -> None:
1677
+ try:
1678
+ profile = await self._save_only()
1679
+ except Exception as exc:
1680
+ self.notify(
1681
+ str(exc),
1682
+ title=self._render_toast(_SAVE_ERROR_TITLE.bind()),
1683
+ severity="error",
1684
+ timeout=5,
1685
+ markup=False,
1686
+ )
1687
+ return
1688
+ self.notify(
1689
+ self._render_toast(_MODEL_PROFILE_SAVED.bind()),
1690
+ title=self._render_toast(_MODEL_SETTINGS_TITLE.bind()),
1691
+ severity="information",
1692
+ timeout=3,
1693
+ markup=False,
1694
+ )
1695
+ self._notify_unsupported_options(profile)
1696
+
1697
+ def _should_offer_prompt_cache(self) -> bool:
1698
+ """Whether this save makes the profile a Claude profile that never asks for prompt caching.
1699
+
1700
+ A profile already saved as a complete Claude profile on the Anthropic
1701
+ protocol has had its answer; one saved as something else since (another
1702
+ provider or model) is asked again when a save makes it one.
1703
+ """
1704
+ if not lacks_anthropic_prompt_cache_option(self._build_profile_from_form()):
1705
+ return False
1706
+ previous = self._registry.get(self._selected_profile_id)
1707
+ return previous is None or not (is_model_profile_selectable(previous) and is_anthropic_claude_profile(previous))
1708
+
1709
+ def _offer_prompt_cache(self) -> None:
1710
+ from chrys.app.tui.screens.dialogs.prompt_cache import ADD_AND_SAVE_RESULT, PromptCacheDialog
1711
+
1712
+ async def _on_choice(choice: PromptCacheDialogResult) -> None:
1713
+ self._prompt_cache_offer_open = False
1714
+ if choice is None or not self.is_attached:
1715
+ return
1716
+ if choice == ADD_AND_SAVE_RESULT:
1717
+ await self._add_prompt_cache_option()
1718
+ await self._save_and_report()
1719
+
1720
+ self._prompt_cache_offer_open = True
1721
+ self.app.push_screen(PromptCacheDialog(locale_controller=widget_locale_controller(self)), callback=_on_choice)
1722
+
1723
+ async def _add_prompt_cache_option(self) -> None:
1724
+ """Merge Anthropic's prompt-caching switch into the form's ``extra_body`` option.
1725
+
1726
+ Edits the existing ``extra_body`` row, else fills the first blank
1727
+ row, else mounts a new one; the save then reads the form as usual.
1728
+ """
1729
+ container = self.query_one("#mc-options-list", Vertical)
1730
+ rows = list(container.query(".mc-kv-item-row"))
1731
+ blank: tuple[Input, Input] | None = None
1732
+ for row in rows:
1733
+ key_input = row.query_one(".mc-kv-key-input", Input)
1734
+ value_input = row.query_one(".mc-kv-value-input", Input)
1735
+ key = key_input.value.strip()
1736
+ if key == "extra_body":
1737
+ raw = value_input.value.strip()
1738
+ value_input.value = json.dumps(with_anthropic_prompt_cache_option(json.loads(raw) if raw else None))
1739
+ return
1740
+ if blank is None and not key and not value_input.value.strip():
1741
+ blank = (key_input, value_input)
1742
+ value = json.dumps(with_anthropic_prompt_cache_option(None))
1743
+ if blank is not None:
1744
+ blank[0].value = "extra_body"
1745
+ blank[1].value = value
1746
+ return
1747
+ add_row = self.query_one("#mc-oadd-row", Horizontal)
1748
+ await container.mount(self._compose_kv_row("extra_body", value, "o"), before=add_row)
1749
+
1750
+ @on(Button.Pressed, "#mc-cancel")
1751
+ def _on_cancel(self, _event: Button.Pressed) -> None:
1752
+ self.dismiss(self._cancel_result())
1753
+
1754
+ def action_cancel(self) -> None:
1755
+ self.dismiss(self._cancel_result())
1756
+
1757
+ def _cancel_result(self) -> str:
1758
+ """Return the dismiss result from explicit reload requirements.
1759
+
1760
+ * ``"switched"`` — deleting the runtime-effective profile set
1761
+ the reload latch after reconciling the process pointer.
1762
+ * ``"updated"`` — at least one Save reached disk + registry;
1763
+ the engine must rebuild so profile edits take effect.
1764
+ * ``""`` — no runtime reload is required; a global-default-only
1765
+ deletion applies to future processes.
1766
+ """
1767
+ if self._read_only:
1768
+ return ""
1769
+ if self._requires_runtime_reload:
1770
+ return "switched"
1771
+ if self._saved_anything:
1772
+ return "updated"
1773
+ return ""
1774
+
1775
+ def _default_dismiss_result(self) -> str: # type: ignore[override]
1776
+ return self._cancel_result()
1777
+
1778
+ # ── Validation ───────────────────────────────────────────────────
1779
+
1780
+ def _validate(self) -> list[str]:
1781
+ errors: list[str] = []
1782
+
1783
+ name = self.query_one("#mc-name", Input).value.strip()
1784
+ if not name:
1785
+ errors.append(self._render_toast(_NAME_REQUIRED.bind()))
1786
+ else:
1787
+ # Uniqueness: no other profile may share this name (case-
1788
+ # insensitive — "Default" and "default" collide). Compare by
1789
+ # id so the currently-edited profile is excluded.
1790
+ lowered = name.casefold()
1791
+ for other in self._registry.list_profiles():
1792
+ if other.id != self._selected_profile_id and other.name.casefold() == lowered:
1793
+ errors.append(self._render_toast(_PROFILE_EXISTS.bind(name=DisplayBlock(other.name))))
1794
+ break
1795
+
1796
+ provider = str(self.query_one("#mc-provider", Select).value)
1797
+ if provider not in {p[1] for p in _PROVIDERS}:
1798
+ allowed = ", ".join(p[0] for p in _PROVIDERS)
1799
+ errors.append(self._render_toast(_PROVIDER_ALLOWED.bind(providers=allowed)))
1800
+
1801
+ model = self.query_one("#mc-model", Input).value.strip()
1802
+ if not model:
1803
+ errors.append(self._render_toast(_MODEL_REQUIRED.bind()))
1804
+ else:
1805
+ model_error = model_id_charset_error(model)
1806
+ if model_error:
1807
+ errors.append(model_error)
1808
+
1809
+ errors.extend(
1810
+ self._validate_required_positive_int(
1811
+ "#mc-max-tokens",
1812
+ _MAX_CONTEXT_TOKENS_LABEL,
1813
+ example=DEFAULT_MAX_CONTEXT_TOKENS,
1814
+ )
1815
+ )
1816
+ errors.extend(
1817
+ self._validate_required_positive_int(
1818
+ "#mc-max-output-tokens",
1819
+ _MAX_OUTPUT_TOKENS_LABEL,
1820
+ example=DEFAULT_MAX_OUTPUT_TOKENS,
1821
+ )
1822
+ )
1823
+ max_context_text = self.query_one("#mc-max-tokens", Input).value.strip()
1824
+ max_output_text = self.query_one("#mc-max-output-tokens", Input).value.strip()
1825
+ try:
1826
+ max_context_tokens = int(max_context_text)
1827
+ except ValueError:
1828
+ max_context_tokens = 0
1829
+ try:
1830
+ max_output_tokens = int(max_output_text)
1831
+ except ValueError:
1832
+ max_output_tokens = 0
1833
+ if 0 < max_context_tokens < MIN_DERIVABLE_CONTEXT_TOKENS:
1834
+ errors.append(self._render_toast(_MAX_CONTEXT_MINIMUM.bind(minimum=MIN_DERIVABLE_CONTEXT_TOKENS)))
1835
+ if max_context_tokens > 0 and max_output_tokens > 0 and max_output_tokens >= max_context_tokens:
1836
+ errors.append(self._render_toast(_MAX_OUTPUT_LESS_THAN_CONTEXT.bind()))
1837
+
1838
+ base_url = self.query_one("#mc-base-url", Input).value.strip()
1839
+ if base_url and not base_url.startswith(("http://", "https://")):
1840
+ errors.append(self._render_toast(_BASE_URL_SCHEME.bind()))
1841
+
1842
+ # A pasted key with a stray non-ASCII character (emoji, CJK text,
1843
+ # smart quotes) would otherwise only fail at request time, as an
1844
+ # opaque encode error. ``{{ENV_VAR}}`` templates are ASCII and
1845
+ # pass untouched; their resolved values are re-checked when the
1846
+ # client is built.
1847
+ api_key = self.query_one("#mc-api-key", Input).value.strip()
1848
+ api_key_error = api_key_charset_error(api_key)
1849
+ if api_key_error:
1850
+ errors.append(api_key_error)
1851
+
1852
+ # HTTP timeouts
1853
+ for field_id, label_definition in [
1854
+ ("#mc-connect-timeout", _HTTP_CONNECT_TIMEOUT_LABEL),
1855
+ ("#mc-read-timeout", _HTTP_READ_TIMEOUT_LABEL),
1856
+ ]:
1857
+ label = self._render_toast(label_definition.bind())
1858
+ val = self.query_one(field_id, Input).value.strip()
1859
+ if val:
1860
+ try:
1861
+ f = float(val)
1862
+ if f <= 0:
1863
+ errors.append(self._render_toast(_FIELD_POSITIVE_NUMBER.bind(label=label)))
1864
+ except ValueError:
1865
+ errors.append(self._render_toast(_FIELD_VALID_NUMBER.bind(label=label)))
1866
+
1867
+ retries = self.query_one("#mc-max-retries", Input).value.strip()
1868
+ if retries:
1869
+ try:
1870
+ r = int(retries)
1871
+ if r < 0:
1872
+ errors.append(self._render_toast(_HTTP_RETRIES_NON_NEGATIVE.bind()))
1873
+ except ValueError:
1874
+ errors.append(self._render_toast(_HTTP_RETRIES_VALID.bind()))
1875
+
1876
+ errors.extend(self._validate_kv_list("mc-headers-list", _HTTP_EXTRA_HEADERS, http_headers=True))
1877
+ errors.extend(self._validate_kv_list("mc-options-list", _CHAT_OPTIONS))
1878
+ errors.extend(self._validate_chat_option_values())
1879
+
1880
+ return errors
1881
+
1882
+ # ── Save / Apply ─────────────────────────────────────────────────
1883
+
1884
+ def _build_profile_from_form(self) -> ModelProfile:
1885
+ """Construct a ``ModelProfile`` from the current form state."""
1886
+ from chrys.service.profiles.models.schema import ModelProfile
1887
+
1888
+ headers = self._read_kv_list("mc-headers-list")
1889
+ options = self._read_kv_list("mc-options-list")
1890
+ provider = str(self.query_one("#mc-provider", Select).value)
1891
+ api_style_value = str(self.query_one("#mc-api-style", Select).value)
1892
+ api_style = (
1893
+ API_STYLE_RESPONSES if is_responses_wire_dialect(provider, api_style_value) else API_STYLE_CHAT_COMPLETIONS
1894
+ )
1895
+ # Fields the form has no control for keep their stored value.
1896
+ stored = self._registry.get(self._selected_profile_id)
1897
+
1898
+ return ModelProfile(
1899
+ id=self._selected_profile_id,
1900
+ name=self.query_one("#mc-name", Input).value.strip(),
1901
+ provider=provider,
1902
+ api_style=api_style,
1903
+ model_id=self.query_one("#mc-model", Input).value.strip(),
1904
+ max_context_tokens=int(self.query_one("#mc-max-tokens", Input).value.strip()),
1905
+ max_output_tokens=int(self.query_one("#mc-max-output-tokens", Input).value.strip()),
1906
+ base_url=self.query_one("#mc-base-url", Input).value.strip(),
1907
+ api_key=self.query_one("#mc-api-key", Input).value.strip(),
1908
+ http_connect_timeout=float(self.query_one("#mc-connect-timeout", Input).value.strip() or "10.0"),
1909
+ http_read_timeout=float(self.query_one("#mc-read-timeout", Input).value.strip() or "300.0"),
1910
+ http_max_retries=int(self.query_one("#mc-max-retries", Input).value.strip() or "2"),
1911
+ verify_ssl=not self.query_one("#mc-skip-tls", Checkbox).value,
1912
+ bypass_proxy=self.query_one("#mc-bypass-proxy", Checkbox).value,
1913
+ http_headers=_kv_to_json(headers, parse_values=False),
1914
+ chat_options=_kv_to_json(options),
1915
+ stream=self.query_one("#mc-stream", Checkbox).value,
1916
+ vision=self.query_one("#mc-vision", Checkbox).value,
1917
+ stream_requires_finish_reason=stored is not None and stored.stream_requires_finish_reason,
1918
+ thinking_block_binding=stored.thinking_block_binding if stored is not None else "auto",
1919
+ auto_interleaved_thinking=stored.auto_interleaved_thinking if stored is not None else True,
1920
+ )
1921
+
1922
+ async def _save_only(self) -> ModelProfile | None:
1923
+ """Persist the form to YAML + registry and return the saved profile.
1924
+
1925
+ If the edited profile is the global default — or no usable global
1926
+ default exists yet, in which case the first successful save claims
1927
+ the pointer — also refresh that durable document pointer. A
1928
+ stored default that no longer resolves to a selectable profile
1929
+ (deleted or hollowed out behind our back) counts as absent. The
1930
+ runtime-effective process pointer is deliberately left unchanged.
1931
+ """
1932
+ if self._read_only:
1933
+ return None
1934
+ from chrys.service.profiles.models.env_bridge import get_active_profile_id
1935
+ from chrys.service.profiles.models.resolver import resolve_profile_selector, resolve_selectable_profile
1936
+ from chrys.service.profiles.models.serializer import save_profile
1937
+
1938
+ profile = self._build_profile_from_form()
1939
+ # The process pointer may reference this profile by NAME; capture
1940
+ # that against the pre-save registry, because a rename would strand
1941
+ # such a pointer and modal close would then misread the runtime as
1942
+ # inactive and adopt the global default — silently switching models.
1943
+ process_selector = get_active_profile_id()
1944
+ process_target = resolve_profile_selector(self._registry, process_selector)
1945
+ process_points_here = process_target is not None and process_target.id == profile.id
1946
+ await asyncio.to_thread(save_profile, profile)
1947
+ self._registry.register(profile)
1948
+ if process_points_here and resolve_profile_selector(self._registry, process_selector) is None:
1949
+ set_model_pointer(profile.id, origin=SettingOrigin(layer=Source.PROCESS_RUNTIME))
1950
+ self._saved_anything = True
1951
+ if (
1952
+ self._selected_profile_id == self._global_default_profile_id
1953
+ or resolve_selectable_profile(self._registry, self._global_default_profile_id) is None
1954
+ ):
1955
+ await self._persist_active_profile(profile)
1956
+ self._global_default_profile_id = profile.id
1957
+ # Refresh sidebar so a renamed profile's label reflects the new name.
1958
+ self._populate_sidebar()
1959
+ ol = self.query_one("#mc-list", OptionList)
1960
+ with contextlib.suppress(OptionDoesNotExist):
1961
+ ol.highlighted = ol.get_option_index(self._selected_profile_id)
1962
+ return profile
1963
+
1964
+ def _notify_unsupported_options(self, profile: ModelProfile | None) -> None:
1965
+ """Surface non-blocking warnings for saved-but-unsupported option combos."""
1966
+ if profile is None:
1967
+ return
1968
+ from chrys.service.profiles.models.options import (
1969
+ protected_chat_option_keys_warning_structured,
1970
+ responses_store_continuation_warning,
1971
+ )
1972
+
1973
+ warnings: list[str] = []
1974
+ if responses_store_continuation_warning(profile) is not None:
1975
+ warnings.append(self._render_toast(_RESPONSES_STORE_WARNING.bind()))
1976
+ protected_warning = protected_chat_option_keys_warning_structured(profile)
1977
+ if protected_warning is not None:
1978
+ warnings.append(
1979
+ self._render_toast(
1980
+ _PROTECTED_CHAT_OPTIONS_WARNING.bind(
1981
+ keys=DisplayBlock(", ".join(protected_warning.keys)),
1982
+ )
1983
+ )
1984
+ )
1985
+ for warning in warnings:
1986
+ self.notify(
1987
+ warning,
1988
+ title=self._render_toast(_MODEL_SETTINGS_TITLE.bind()),
1989
+ severity="warning",
1990
+ timeout=8,
1991
+ markup=False,
1992
+ )
1993
+
1994
+ def _render_toast(self, reference: MessageRef) -> str:
1995
+ return render_str(widget_localizer(self), reference)
1996
+
1997
+
1998
+ def _parse_json_dict(raw: str, *, parse_values: bool = True) -> dict[str, str]:
1999
+ """Parse a JSON string into a dict, returning empty dict on failure.
2000
+
2001
+ Non-string values (objects, arrays, booleans, numbers) are
2002
+ re-serialised via ``json.dumps`` so that ``_kv_to_json`` can
2003
+ parse them back losslessly (e.g. ``True`` → ``"true"``, not
2004
+ Python's ``"True"``). With *parse_values* (``_kv_to_json``'s
2005
+ default), a string that would read back as JSON (``"1"``,
2006
+ ``"true"``) or that the form's whitespace trim would change is
2007
+ shown quoted too, so saving the form again keeps it the same string.
2008
+ """
2009
+ if not raw:
2010
+ return {}
2011
+ try:
2012
+ parsed = json.loads(raw)
2013
+ if isinstance(parsed, dict):
2014
+ result: dict[str, str] = {}
2015
+ for k, v in parsed.items():
2016
+ if not isinstance(v, str) or (parse_values and (v != v.strip() or _reads_as_json(v))):
2017
+ v = json.dumps(v)
2018
+ result[str(k)] = v
2019
+ return result
2020
+ except json.JSONDecodeError, TypeError:
2021
+ pass
2022
+ return {}
2023
+
2024
+
2025
+ _NOT_JSON = object()
2026
+
2027
+
2028
+ def _reads_as_json(text: str) -> bool:
2029
+ return _loads_or(text, _NOT_JSON) is not _NOT_JSON
2030
+
2031
+
2032
+ def _kv_to_json(kv: dict[str, str], *, parse_values: bool = True) -> str:
2033
+ """Serialise a key-value dict to a JSON string.
2034
+
2035
+ Values that are valid JSON (objects, arrays, numbers, booleans) are
2036
+ embedded as-is so that nested structures survive the round-trip.
2037
+ Plain strings are kept as strings.
2038
+ """
2039
+ if not kv:
2040
+ return ""
2041
+ if not parse_values:
2042
+ return json.dumps(kv)
2043
+ return json.dumps({k: _loads_or(v, v) for k, v in kv.items()})