alysis-code 0.13.0__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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,2754 @@
1
+ """Presentation-agnostic state machine for the full-screen TUI ``/config`` menu.
2
+
3
+ The classic configuration menu (:func:`cli_impl.config_menu.run_config_menu`) is a
4
+ Rich ``Live`` + ``create_input().raw_mode()`` flow. It cannot run inside the chat
5
+ alt-screen, so when ``ALYSIS_TUI`` is on bare ``/config`` previously degraded to a
6
+ read-only panel. This module re-expresses the *same* menu — Provider Profile · API
7
+ Key · Default Model · Context & Cache · Execution Limits · Subagent overrides ·
8
+ Forge overrides · Sandbox — as a step machine that holds state and renders a small :class:`Screen`
9
+ description. The prompt_toolkit overlay in :mod:`cli_impl.tui.config_overlay` reads
10
+ that description and routes key events back into the flow; nothing here imports
11
+ prompt_toolkit, so the whole thing is unit testable by driving the public methods.
12
+
13
+ The menu *logic* is reused wholesale from :mod:`cli_impl.config_menu`
14
+ (:class:`ConfigMenuState` and its row builders / validators / ``commit_to``), so
15
+ there is a single source of truth for "what config does"; this module only owns
16
+ "how the TUI walks through it". It mirrors the structure of
17
+ :mod:`cli_impl.tui.setup_flow`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from pathlib import Path
24
+ from typing import Any
25
+ from urllib.parse import urlparse
26
+
27
+ from ...config import (
28
+ ConfigError,
29
+ _normalize_web_search_mode,
30
+ clear_persisted_api_key,
31
+ clear_persisted_profile_key,
32
+ load_config,
33
+ save_config,
34
+ save_persisted_api_key,
35
+ save_persisted_profile_key,
36
+ )
37
+ from ...llm.protocols import validate_reasoning_trace_adapter_for_protocol
38
+ from ...profile_presets import (
39
+ PROFILE_PRESETS,
40
+ make_profile_from_preset,
41
+ preset_selection_label,
42
+ )
43
+ from ...profiles import ProfileSpec, validate_base_url
44
+ from ...provider_auth import ProviderAccountStatus, ProviderAuthError, create_provider_auth
45
+ from ...provider_diagnostics import provider_diagnostic_warning_lines
46
+ from ...reasoning_contracts import (
47
+ ALWAYS_ON,
48
+ OFF_SWAPS_MODEL,
49
+ UNKNOWN_CONTRACT,
50
+ ReasoningContract,
51
+ reasoning_contract_for,
52
+ reasoning_labels_allowed_by_contract,
53
+ )
54
+ from ...sandbox_settings import normalize_sandbox_mode
55
+ from ...web_search_adapters import normalize_web_search_adapter
56
+ from ...web_search_policy import normalize_web_search_policy
57
+ from ...workspace_binding import (
58
+ WorkspaceBindingError,
59
+ discover_workspace_candidates,
60
+ resolve_workspace_binding,
61
+ )
62
+ from ..config_menu import (
63
+ _ADVANCED_PROVIDER_PRESETS_VALUE,
64
+ _CLEAR_KEY_PENDING,
65
+ _CUSTOM_MODEL_VALUE,
66
+ _MISSING_REQUIRED,
67
+ _ROLE_TEMPERATURE_FIELDS,
68
+ ANTHROPIC_PROMPT_CACHE_TTLS,
69
+ FORGE_ROLE_ORDER,
70
+ PROMPT_CACHE_MODES,
71
+ ROLE_ORDER,
72
+ ConfigMenuResult,
73
+ ConfigMenuState,
74
+ _active_preset,
75
+ _active_subscription_profile,
76
+ _advanced_profile_presets_for_setup,
77
+ _api_key_summary_text,
78
+ _cache_aware_summary_text,
79
+ _cache_policy_summary_text,
80
+ _default_model_picker_subtitle,
81
+ _default_model_rows,
82
+ _finite_float,
83
+ _format_number,
84
+ _is_direct_subscription_id,
85
+ _looks_like_secret,
86
+ _normalize_anthropic_prompt_cache_ttl,
87
+ _normalize_bool_text,
88
+ _normalize_cache_aware_min_trigger_ratio,
89
+ _normalize_prompt_cache_mode,
90
+ _ordered_profile_presets_for_setup,
91
+ _override_summary_text,
92
+ _parse_header_text,
93
+ _pending_change_count,
94
+ _resolved_cache_policy_for_state,
95
+ _role_description,
96
+ _role_label,
97
+ _runtime_setup_rows,
98
+ _sandbox_mode_env_override,
99
+ _temperature_controls_available,
100
+ _thinking_labels_for_state,
101
+ _web_search_mode_rows,
102
+ _web_search_policy_rows,
103
+ )
104
+ from ..setup_wizard import _suggest_workspace_default
105
+ from .setup_flow import Mode, Row, Screen, Tone
106
+
107
+ # The literal a user types in a profile-edit field to keep a value that tripped the
108
+ # accidental-secret guard (mirrors ``config_menu._SECRET_FORCE_TOKEN``).
109
+ _SECRET_FORCE_TOKEN = "force"
110
+
111
+ # Row value for the Alysis Code account on the AI-subscription screen. It is not
112
+ # a delegated runtime: connecting runs the device-flow login and activates the
113
+ # native `alysis` profile (free daily hosted DeepSeek).
114
+ _ALYSIS_CONNECTION_ID = "alysis"
115
+
116
+ # Sentinel row values on the top-level menu for the two trailing action rows.
117
+ _SAVE = "__save__"
118
+ _CANCEL = "__cancel__"
119
+
120
+ # One-line preset descriptions for the TUI picker; the full protocol prose from
121
+ # ``_preset_description`` stays in the classic Rich menu and docs.
122
+ _NATIVE_PROTOCOL_SHORT = {
123
+ "openai_responses": "native · OpenAI Responses",
124
+ "anthropic_messages": "native · Anthropic Messages",
125
+ "gemini_generate_content": "native · Gemini GenerateContent",
126
+ }
127
+
128
+
129
+ def _short_preset_label(preset: Any) -> str:
130
+ # "OpenAI - Native Responses" → "OpenAI"; the description carries the rest.
131
+ return preset_selection_label(preset).split(" - ")[0]
132
+
133
+
134
+ def _thinking_labels_allowed_by_contract(
135
+ contract: ReasoningContract, labels: list[str], *, current: str
136
+ ) -> list[str]:
137
+ """Filter thinking-picker options to what the model's contract accepts.
138
+
139
+ Rules (capability report, Part A): an ``always-on`` model never shows
140
+ "off" — unless "off" *swaps the model* (kimi-code), which stays visible so
141
+ the substitution can be warned about instead of hidden. When the contract
142
+ publishes an exact value set, out-of-set efforts are dropped. The current
143
+ selection is never hidden (the user must always be able to see and change
144
+ it), and an unknown contract leaves the list untouched.
145
+ """
146
+ return reasoning_labels_allowed_by_contract(contract, labels, current=current)
147
+
148
+
149
+ def _cache_mode_subtitle(state: Any) -> str:
150
+ """Plain-language caching status for the section header.
151
+
152
+ The old subtitle was the raw policy dump ("Effective: unsupported;
153
+ strategy=none; source=default; allowed=none; emits=no fields; usage=none")
154
+ — debug output, not UI. The full detail remains available in /status.
155
+ """
156
+ raw = _cache_policy_summary_text(_resolved_cache_policy_for_state(state), compact=True)
157
+ if "unsupported" in raw:
158
+ return (
159
+ "The active provider does not support prompt caching; "
160
+ "these settings apply where a provider does."
161
+ )
162
+ return f"Provider caching: {raw}"
163
+
164
+
165
+ def _pretty_key_source(source: str | None) -> str:
166
+ """Humanize ``api_key_source`` values (``stored:profile=x`` → prose)."""
167
+ raw = str(source or "").strip()
168
+ if raw.startswith("stored:profile="):
169
+ return f"saved in profile {raw.removeprefix('stored:profile=')}"
170
+ if raw.startswith("env:"):
171
+ return f"from ${raw.removeprefix('env:')}"
172
+ if raw == "stored":
173
+ return "saved in config"
174
+ return raw or "not set"
175
+
176
+
177
+ def _short_preset_description(preset: Any) -> str:
178
+ if preset.key == "alysis":
179
+ return "hosted MiMo — Alysis Code account"
180
+ short = _NATIVE_PROTOCOL_SHORT.get(preset.protocol)
181
+ if short:
182
+ return short
183
+ host = urlparse(preset.base_url).netloc
184
+ return host or "any OpenAI-compatible base URL"
185
+
186
+
187
+ # stage → interaction mode, so the overlay's key-binding filters don't have to
188
+ # build a full :class:`Screen` on every repaint.
189
+ _STAGE_MODE: dict[str, Mode] = {
190
+ "menu": "list",
191
+ "execution_backend": "list",
192
+ "execution_runtime": "list",
193
+ "subscription_account": "list",
194
+ "subscription_disconnect_confirm": "confirm",
195
+ "subscription_connecting": "busy",
196
+ "subscription_disconnecting": "busy",
197
+ "advanced": "list",
198
+ "subagent_roles": "list",
199
+ "forge_roles": "list",
200
+ "workspace": "list",
201
+ "workspace_path": "input",
202
+ "workspace_action": "list",
203
+ "switching": "busy",
204
+ "sandbox": "list",
205
+ "personas": "list",
206
+ "model": "list",
207
+ "custom_model": "input",
208
+ "model_base_url": "input",
209
+ "model_thinking": "list",
210
+ "model_timeout": "input",
211
+ "web_search_policy": "list",
212
+ "web_search_mode": "list",
213
+ "cache_mode": "list",
214
+ "cache_key": "input",
215
+ "cache_retention": "input",
216
+ "cache_anthropic_enabled": "list",
217
+ "cache_anthropic_ttl": "list",
218
+ "cache_compaction_enabled": "list",
219
+ "cache_compaction_min_trigger": "input",
220
+ "subagent_field": "input",
221
+ "forge_field": "input",
222
+ "api_key": "input",
223
+ "api_key_clear_confirm": "confirm",
224
+ "provider": "list",
225
+ "provider_switch": "list",
226
+ "provider_add_preset": "list",
227
+ "provider_add_preset_advanced": "list",
228
+ "provider_preset_name": "input",
229
+ "provider_preset_url": "input",
230
+ "provider_custom_name": "input",
231
+ "provider_custom_url": "input",
232
+ "provider_custom_headers": "input",
233
+ "provider_edit": "input",
234
+ "provider_remove": "list",
235
+ "provider_remove_confirm": "confirm",
236
+ "saving": "busy",
237
+ "cancel_confirm": "confirm",
238
+ "done": "done",
239
+ }
240
+
241
+ # Esc/back: the stage to return to from non-sequence stages.
242
+ _PREV: dict[str, str] = {
243
+ "advanced": "menu",
244
+ "execution_backend": "menu",
245
+ "execution_runtime": "execution_backend",
246
+ "subscription_account": "execution_runtime",
247
+ "subscription_disconnect_confirm": "subscription_account",
248
+ "workspace": "menu",
249
+ "workspace_path": "workspace",
250
+ "workspace_action": "workspace",
251
+ "sandbox": "menu",
252
+ "personas": "menu",
253
+ "model": "menu",
254
+ "custom_model": "model",
255
+ "model_base_url": "model",
256
+ "model_thinking": "model_base_url",
257
+ "model_timeout": "model_thinking",
258
+ "web_search_policy": "menu",
259
+ "web_search_mode": "web_search_policy",
260
+ "cache_mode": "menu",
261
+ "cache_key": "cache_mode",
262
+ "cache_retention": "cache_key",
263
+ "cache_anthropic_enabled": "cache_retention",
264
+ "cache_anthropic_ttl": "cache_anthropic_enabled",
265
+ "cache_compaction_enabled": "cache_anthropic_ttl",
266
+ "cache_compaction_min_trigger": "cache_compaction_enabled",
267
+ "api_key": "menu",
268
+ "api_key_clear_confirm": "api_key",
269
+ "provider": "menu",
270
+ "provider_switch": "provider",
271
+ "provider_add_preset": "provider",
272
+ "provider_add_preset_advanced": "provider_add_preset",
273
+ "provider_preset_name": "provider_add_preset",
274
+ "provider_preset_url": "provider_preset_name",
275
+ "provider_custom_name": "provider",
276
+ "provider_custom_url": "provider_custom_name",
277
+ "provider_custom_headers": "provider_custom_url",
278
+ "provider_edit": "provider",
279
+ "provider_remove": "provider",
280
+ "provider_remove_confirm": "provider_remove",
281
+ }
282
+
283
+ # stage → footer breadcrumb label.
284
+ _BREADCRUMB: dict[str, str] = {
285
+ "menu": "configuration",
286
+ "execution_backend": "model access",
287
+ "execution_runtime": "model access",
288
+ "subscription_account": "model access",
289
+ "subscription_disconnect_confirm": "model access",
290
+ "subscription_connecting": "model access",
291
+ "subscription_disconnecting": "model access",
292
+ "advanced": "advanced",
293
+ "workspace": "project",
294
+ "workspace_path": "project",
295
+ "workspace_action": "project",
296
+ "switching": "saving",
297
+ "sandbox": "sandbox",
298
+ "personas": "personas",
299
+ "model": "default model",
300
+ "custom_model": "default model",
301
+ "model_base_url": "default model",
302
+ "model_thinking": "default model",
303
+ "model_timeout": "default model",
304
+ "web_search_policy": "web search",
305
+ "web_search_mode": "web search",
306
+ "cache_mode": "context & cache",
307
+ "cache_key": "context & cache",
308
+ "cache_retention": "context & cache",
309
+ "cache_anthropic_enabled": "context & cache",
310
+ "cache_anthropic_ttl": "context & cache",
311
+ "cache_compaction_enabled": "context & cache",
312
+ "cache_compaction_min_trigger": "context & cache",
313
+ "subagent_field": "subagent overrides",
314
+ "forge_field": "forge overrides",
315
+ "api_key": "api key",
316
+ "api_key_clear_confirm": "api key",
317
+ "saving": "saving",
318
+ "cancel_confirm": "configuration",
319
+ }
320
+
321
+ _THINKING_DESCRIPTIONS = {
322
+ "off": "no extra reasoning tokens",
323
+ "minimal": "minimal reasoning budget",
324
+ "low": "small reasoning budget",
325
+ "medium": "medium reasoning budget",
326
+ "high": "large reasoning budget",
327
+ "xhigh": "maximum reasoning budget when supported",
328
+ "max": "provider maximum reasoning budget when supported",
329
+ "ultra": "provider ultra reasoning budget when supported",
330
+ "auto": "let the provider decide",
331
+ }
332
+ _CACHE_MODE_DESCRIPTIONS = {
333
+ "auto": "derive provider-aware cache settings when supported",
334
+ "manual": "use the manual key/retention below when supported",
335
+ "off": "disable prompt caching",
336
+ }
337
+ _CACHE_TTL_DESCRIPTIONS = {
338
+ "5m": "lower lifetime; safest default",
339
+ "1h": "longer lifetime for stable coding sessions",
340
+ }
341
+ _PROVIDER_ACTION_ROWS = (
342
+ ("switch", "Switch active profile", "Choose another configured profile."),
343
+ ("add_preset", "Add from preset", "Pick a known provider (OpenAI, Anthropic, …)"),
344
+ ("add_custom", "Add custom", "Use any OpenAI-compatible base URL"),
345
+ ("edit", "Edit current", "Change URL, model, trace adapter, headers, and notes"),
346
+ ("remove", "Remove", "Delete a profile (applied on save)"),
347
+ ("back", "Back", "Return to the menu"),
348
+ )
349
+ _EDIT_FIELDS = (
350
+ "base_url",
351
+ "api_key_env",
352
+ "default_model",
353
+ "reasoning_trace_adapter",
354
+ "web_search_adapter",
355
+ "web_search_model",
356
+ "extra_headers",
357
+ "notes",
358
+ )
359
+ _EDIT_LABELS = {
360
+ "base_url": "Base URL",
361
+ "api_key_env": "API key env var NAME (not the key itself)",
362
+ "default_model": "Default model",
363
+ "reasoning_trace_adapter": "Reasoning trace adapter",
364
+ "web_search_adapter": "Web search adapter",
365
+ "web_search_model": "Web search model",
366
+ "extra_headers": "Extra headers (k=v, comma-separated)",
367
+ "notes": "Notes",
368
+ }
369
+
370
+
371
+ def _strip_markup(text: str) -> str:
372
+ """Drop Rich ``[style]…[/style]`` tags so menu summaries render as plain text."""
373
+ import re
374
+
375
+ return re.sub(r"\[/?[a-z][a-z0-9 _#-]*\]", "", str(text)).strip()
376
+
377
+
378
+ class ConfigFlow:
379
+ """Drives the TUI configuration menu one screen at a time.
380
+
381
+ The overlay calls :meth:`screen` to render, the navigation/selection methods on
382
+ key events, and — on the single ``saving`` busy stage — :meth:`run_busy` on a
383
+ worker. Every transition is synchronous and side-effect-explicit so tests can
384
+ walk the whole flow without a terminal.
385
+ """
386
+
387
+ def __init__(self, *, cfg: Any | None = None, current_workspace: str | None = None) -> None:
388
+ self.cfg = cfg if cfg is not None else load_config()
389
+ self.state = ConfigMenuState.from_cfg(self.cfg)
390
+ # The live session's workspace root (display only); the running session is
391
+ # bound to it and cannot change it in place — switching restarts the chat.
392
+ self.current_workspace = str(current_workspace or "")
393
+ self.stage = "menu"
394
+ self.index = 0
395
+ self.status = ""
396
+ self.status_tone: Tone = "dim"
397
+ # Terminal results.
398
+ self.success: bool | None = None
399
+ self.saved: bool = False
400
+ self.result: ConfigMenuResult | None = None
401
+ self.changes_count: int = 0
402
+ # Set when the user chooses "Switch now": the overlay reads it on close and
403
+ # asks the host to relaunch the chat bound to this folder (fresh session).
404
+ self.switch_workspace: str | None = None
405
+ self._switch_target: str | None = None
406
+ self._pending_workspace = ""
407
+ # Discovered project candidates are cached (filesystem scan); screen() is
408
+ # pull-based and runs on every repaint, so we must not re-scan per frame.
409
+ self._ws_candidates_cache: list[Any] | None = None
410
+ # Transient sub-flow state.
411
+ self._resume_stage = "menu"
412
+ self._sub_steps: list[tuple[str, str]] = []
413
+ self._sub_i = 0
414
+ self._forge_role = ""
415
+ self._sub_model_snapshot: dict[str, str] = {}
416
+ self._sub_temp_snapshot: dict[str, str] = {}
417
+ self._forge_i = 0
418
+ self._forge_snapshot: dict[str, str] = {}
419
+ self._pending_preset: Any = None
420
+ self._pending_preset_profile: ProfileSpec | None = None
421
+ self._preset_setup_chain = False
422
+ self._custom_name = ""
423
+ self._custom_url = ""
424
+ self._edit_profile: ProfileSpec | None = None
425
+ self._edit_values: dict[str, str] = {}
426
+ self._edit_i = 0
427
+ self._edit_secret_candidate: str | None = None
428
+ self._pending_remove = ""
429
+ self._subscription_provider_id = ""
430
+ self._subscription_account_status: ProviderAccountStatus | None = None
431
+ self._subscription_switch_account = False
432
+ self._subscription_action_outcome: tuple[str, ProviderAccountStatus | None, str] | None = (
433
+ None
434
+ )
435
+ # Save outcome recorded by the blocking I/O step (:meth:`_perform_save`) and
436
+ # applied as a stage transition by :meth:`apply_save_outcome`. Splitting the
437
+ # two lets the overlay run the I/O on a worker while every renderer-visible
438
+ # stage/status write stays on the UI thread.
439
+ self._save_outcome: tuple[str, str] | None = None
440
+
441
+ # ---------------------------------------------------------------- helpers
442
+
443
+ def current_mode(self) -> Mode:
444
+ return _STAGE_MODE.get(self.stage, "message")
445
+
446
+ def busy_kind(self) -> str:
447
+ return "thread"
448
+
449
+ def open_default_model(self) -> None:
450
+ """Open the model picker with the configured model selected."""
451
+ self._goto("model", index=self._model_index())
452
+
453
+ def field_key(self) -> str:
454
+ """A key that changes whenever the active input field changes.
455
+
456
+ The overlay seeds/clears its input box when this changes, so a single
457
+ ``input`` stage that walks several fields (subagent/forge/edit sequences)
458
+ re-seeds correctly between fields.
459
+ """
460
+ if self.stage == "subagent_field":
461
+ return f"subagent_field:{self._sub_i}"
462
+ if self.stage == "forge_field":
463
+ return f"forge_field:{self._forge_role or '0'}"
464
+ if self.stage == "provider_edit":
465
+ return f"provider_edit:{self._edit_i}"
466
+ return self.stage
467
+
468
+ def _set_status(self, text: str, tone: Tone = "dim") -> None:
469
+ self.status = text
470
+ self.status_tone = tone
471
+
472
+ def _goto(self, stage: str, *, index: int = 0, keep_status: bool = False) -> None:
473
+ if stage == "menu" and index == 0:
474
+ index = getattr(self, "_menu_resume_index", 0)
475
+ self.stage = stage
476
+ self.index = index
477
+ if not keep_status:
478
+ self.status = ""
479
+ self.status_tone = "dim"
480
+
481
+ def _finish(self, success: bool) -> None:
482
+ self.success = success
483
+ self.stage = "done"
484
+
485
+ def _breadcrumb(self) -> str:
486
+ if self.stage.startswith("provider"):
487
+ return "provider profile"
488
+ return _BREADCRUMB.get(self.stage, "configuration")
489
+
490
+ def _provider_diag_first(self) -> str:
491
+ try:
492
+ issues = list(provider_diagnostic_warning_lines(self.state._resolution_cfg()))
493
+ except Exception: # noqa: BLE001 - diagnostics are best-effort
494
+ return ""
495
+ return issues[0] if issues else ""
496
+
497
+ # ------------------------------------------------------------- rendering
498
+
499
+ def screen(self) -> Screen:
500
+ builder = getattr(self, f"_screen_{self.stage}", None)
501
+ if builder is None:
502
+ scr = Screen(
503
+ stage=self.stage,
504
+ mode="message",
505
+ title="Configuration",
506
+ lines=[(f"(unknown stage {self.stage})", "err")],
507
+ )
508
+ else:
509
+ scr = builder()
510
+ scr.index = self.index
511
+ # Never leave the cursor on a header/spacer row (e.g. index 0 of the
512
+ # grouped menu): snap forward to the first selectable item.
513
+ if scr.mode == "list" and scr.rows:
514
+ n = len(scr.rows)
515
+ i = scr.index if 0 <= scr.index < n else 0
516
+ for _ in range(n):
517
+ if getattr(scr.rows[i], "kind", "item") == "item":
518
+ break
519
+ i = (i + 1) % n
520
+ scr.index = i
521
+ self.index = i
522
+ if not scr.status:
523
+ scr.status = self.status
524
+ scr.status_tone = self.status_tone
525
+ scr.progress = self._breadcrumb()
526
+ scr.success = self.success
527
+ return scr
528
+
529
+ def _screen_menu(self) -> Screen:
530
+ # Grouped, breathing layout: one short fact per row — the full detail
531
+ # lives inside each section screen. Per-role model overrides (subagent +
532
+ # forge) stay tucked under a single "Advanced" entry.
533
+ st = self.state
534
+ delegated = st.execution_backend == "delegated" and not _is_direct_subscription_id(
535
+ st.execution_runtime
536
+ )
537
+ direct_sub = st.execution_backend == "delegated" and _is_direct_subscription_id(
538
+ st.execution_runtime
539
+ )
540
+ native_suffix = " (inactive)" if delegated else ""
541
+ profile_desc, profile_tone = self._short_profile(delegated)
542
+ model_desc, model_tone = self._short_model(delegated)
543
+ key_desc, key_tone = self._short_api_key(delegated, direct_sub)
544
+ access_desc, access_tone = self._short_execution()
545
+
546
+ rows = [
547
+ Row(label="Workspace", kind="header"),
548
+ Row(label="Project", description=self._short_workspace(), value="workspace"),
549
+ Row(label="Sandbox", description=self._short_sandbox(), value="sandbox"),
550
+ Row(label="Personas", description=self._short_personas(), value="personas"),
551
+ Row(label="", kind="spacer"),
552
+ Row(label="Model", kind="header"),
553
+ Row(label="Model Access", description=access_desc, value="execution", tone=access_tone),
554
+ Row(
555
+ label=f"Provider Profile{native_suffix}",
556
+ description=profile_desc,
557
+ value="profile",
558
+ tone=profile_tone,
559
+ ),
560
+ Row(
561
+ label=f"Default Model{native_suffix}",
562
+ description=model_desc,
563
+ value="default",
564
+ tone=model_tone,
565
+ ),
566
+ # The status ("not used" / "inactive") lives in the description —
567
+ # repeating it as a label suffix read twice on one row.
568
+ Row(
569
+ label="API Key",
570
+ description=key_desc,
571
+ value="api_key",
572
+ tone=key_tone,
573
+ ),
574
+ Row(label="", kind="spacer"),
575
+ Row(label="Behavior", kind="header"),
576
+ Row(label="Web Search", description=self._short_web_search(), value="web_search"),
577
+ Row(label="Context & Cache", description=self._short_cache(), value="cache"),
578
+ Row(label="Advanced", description=self._short_advanced(), value="advanced"),
579
+ Row(label="", kind="spacer"),
580
+ Row(label="Save & exit", description="write changes to disk and close", value=_SAVE),
581
+ Row(label="Discard & close", description="close without saving", value=_CANCEL),
582
+ ]
583
+ pending = _pending_change_count(self.state)
584
+ subtitle = f"{pending} pending change(s)" if pending else "No pending changes."
585
+ if self.state.config_warning:
586
+ subtitle += f" · {self.state.config_warning}"
587
+ return Screen(
588
+ stage="menu",
589
+ mode="list",
590
+ title="Alysis Code configuration",
591
+ subtitle=subtitle,
592
+ rows=rows,
593
+ numbered=False,
594
+ hint="",
595
+ )
596
+
597
+ # ------------------------------------------------- short menu summaries
598
+ #
599
+ # The grouped top-level menu shows ONE short fact per row; the dense
600
+ # multi-fragment summaries remain available inside each section screen.
601
+
602
+ def _short_workspace(self) -> str:
603
+ if self.current_workspace:
604
+ return Path(self.current_workspace).name
605
+ default = self.state.default_workspace_path
606
+ if default:
607
+ return f"default: {Path(default).name}"
608
+ return "not set"
609
+
610
+ def _short_sandbox(self) -> str:
611
+ mode = normalize_sandbox_mode(self.state.fields.get("sandbox_mode", "strict"))
612
+ base = {
613
+ "strict": "strict — sandboxed",
614
+ "warn": "warn — fail-closed",
615
+ "off": "off — host execution",
616
+ }[mode]
617
+ if _sandbox_mode_env_override() is not None:
618
+ return f"{base} · env override"
619
+ return base
620
+
621
+ def _short_execution(self) -> tuple[str, str]:
622
+ st = self.state
623
+ if st.execution_backend == "native":
624
+ return "API key", ""
625
+ runtime = str(st.execution_runtime or "").strip()
626
+ if not runtime:
627
+ return "subscription not configured", "warn"
628
+ labels = {value: label for value, label, _description in _runtime_setup_rows()}
629
+ return labels.get(runtime, runtime), ""
630
+
631
+ def _short_profile(self, delegated: bool) -> tuple[str, str]:
632
+ st = self.state
633
+ if st.active_profile:
634
+ return st.active_profile, ""
635
+ if st.profiles:
636
+ return f"{len(st.profiles)} profiles, none active", "" if delegated else "warn"
637
+ return ("not configured", "") if delegated else ("missing — required", "warn")
638
+
639
+ def _short_model(self, delegated: bool) -> tuple[str, str]:
640
+ model = self.state.fields.get("model", "").strip()
641
+ if model:
642
+ return model, ""
643
+ return ("not configured", "") if delegated else ("missing — required", "warn")
644
+
645
+ def _short_api_key(self, delegated: bool, direct_sub: bool) -> tuple[str, str]:
646
+ if direct_sub:
647
+ return "not used", ""
648
+ if delegated:
649
+ return "inactive", ""
650
+ raw = _api_key_summary_text(self.state)
651
+ if raw == _MISSING_REQUIRED:
652
+ return "missing — required", "warn"
653
+ if raw == _CLEAR_KEY_PENDING:
654
+ return "cleared on save", "warn"
655
+ return _strip_markup(raw), ""
656
+
657
+ def _short_web_search(self) -> str:
658
+ policy = normalize_web_search_policy(self.state.fields.get("web_search_policy", "auto"))
659
+ return "model decides" if policy == "auto" else "off"
660
+
661
+ def _short_cache(self) -> str:
662
+ mode = _normalize_prompt_cache_mode(self.state.fields.get("prompt_cache_mode", "manual"))
663
+ compaction = _cache_aware_summary_text(self.state).split(" · ")[0]
664
+ return f"{mode} · {compaction}"
665
+
666
+ def _short_advanced(self) -> str:
667
+ sub = self._subagent_override_summary()
668
+ forge = _override_summary_text(self.state.forge_role_models)
669
+ if sub == "none" and forge == "none":
670
+ return "no overrides"
671
+ return f"subagents {sub} · forge {forge}"
672
+
673
+ def _screen_execution_backend(self) -> Screen:
674
+ return Screen(
675
+ stage="execution_backend",
676
+ mode="list",
677
+ title="Model Access",
678
+ subtitle="How would you like to connect Alysis Code to AI models?",
679
+ rows=[
680
+ Row(
681
+ label="Use an API key",
682
+ description="Connect directly to a supported model provider.",
683
+ value="native",
684
+ current=self.state.execution_backend == "native",
685
+ ),
686
+ Row(
687
+ label="Use an AI subscription",
688
+ description=(
689
+ "Sign in through a supported provider connection; API-key settings stay saved."
690
+ ),
691
+ value="delegated",
692
+ current=self.state.execution_backend == "delegated",
693
+ ),
694
+ ],
695
+ hint="",
696
+ )
697
+
698
+ def _screen_execution_runtime(self) -> Screen:
699
+ # The Alysis Code account leads: browser sign-in, no API key, free daily
700
+ # hosted DeepSeek. It activates the native `alysis` profile rather
701
+ # than a delegated runtime.
702
+ rows = [
703
+ Row(
704
+ label="Alysis Code account",
705
+ description="Free daily DeepSeek included — browser sign-in, no API key.",
706
+ value=_ALYSIS_CONNECTION_ID,
707
+ current=(
708
+ self.state.execution_backend == "native"
709
+ and self.state.active_profile == _ALYSIS_CONNECTION_ID
710
+ ),
711
+ )
712
+ ]
713
+ rows.extend(
714
+ Row(
715
+ label=label,
716
+ description=description,
717
+ value=runtime_id,
718
+ current=runtime_id == self.state.execution_runtime,
719
+ )
720
+ for runtime_id, label, description in _runtime_setup_rows()
721
+ )
722
+ return Screen(
723
+ stage="execution_runtime",
724
+ mode="list",
725
+ title="AI Subscription",
726
+ subtitle=("Choose a provider to connect, reconnect, switch accounts, or disconnect."),
727
+ rows=rows,
728
+ hint="",
729
+ )
730
+
731
+ def _refresh_subscription_account_status(self) -> ProviderAccountStatus:
732
+ provider_id = self._subscription_provider_id or self.state.execution_runtime
733
+ if provider_id == _ALYSIS_CONNECTION_ID:
734
+ from ... import account_login
735
+
736
+ login = account_login.login_status(self.cfg)
737
+ status = ProviderAccountStatus(
738
+ connected=login.logged_in,
739
+ verified=login.logged_in,
740
+ account_label=("Alysis Code account" if login.logged_in else None),
741
+ detail=(
742
+ "Free daily DeepSeek included."
743
+ if login.logged_in
744
+ else "Sign in with your browser — no API key needed."
745
+ ),
746
+ )
747
+ self._subscription_account_status = status
748
+ return status
749
+ try:
750
+ status = create_provider_auth(provider_id).account_status()
751
+ except (ProviderAuthError, ValueError) as exc:
752
+ status = ProviderAccountStatus(
753
+ connected=False,
754
+ verified=False,
755
+ detail=str(exc),
756
+ )
757
+ self._subscription_account_status = status
758
+ return status
759
+
760
+ def _screen_subscription_account(self) -> Screen:
761
+ status = self._subscription_account_status or self._refresh_subscription_account_status()
762
+ account = status.account_label or ("connected" if status.connected else "not connected")
763
+ rows = [
764
+ Row(
765
+ label=("Reconnect / switch account" if status.connected else "Connect account"),
766
+ description="Open the provider sign-in flow in your browser.",
767
+ value="connect",
768
+ )
769
+ ]
770
+ if status.connected:
771
+ rows.append(
772
+ Row(
773
+ label="Disconnect account",
774
+ description="Remove locally stored subscription credentials.",
775
+ value="disconnect",
776
+ )
777
+ )
778
+ provider_id = self._subscription_provider_id or self.state.execution_runtime
779
+ if status.connected and provider_id == _ALYSIS_CONNECTION_ID:
780
+ # Alysis Code login already activated + persisted the hosted profile,
781
+ # so there is nothing left to save — offer a direct exit to chat.
782
+ rows.append(
783
+ Row(
784
+ label="Done — start chatting",
785
+ description="Close configuration and return to chat.",
786
+ value="chat",
787
+ )
788
+ )
789
+ rows.append(Row(label="Back", description="Return to configuration.", value="back"))
790
+ detail = str(status.detail or "").strip()
791
+ subtitle = f"Account: {account}"
792
+ if detail:
793
+ subtitle += f" · {detail}"
794
+ return Screen(
795
+ stage="subscription_account",
796
+ mode="list",
797
+ title="AI Subscription Account",
798
+ subtitle=subtitle,
799
+ rows=rows,
800
+ hint="",
801
+ )
802
+
803
+ def _screen_subscription_disconnect_confirm(self) -> Screen:
804
+ return Screen(
805
+ stage="subscription_disconnect_confirm",
806
+ mode="confirm",
807
+ title="Disconnect AI subscription",
808
+ lines=[("Remove the locally stored subscription credentials?", "warn")],
809
+ hint="Y disconnect · N keep · Esc back",
810
+ confirm_default=False,
811
+ )
812
+
813
+ def _screen_subscription_connecting(self) -> Screen:
814
+ return Screen(
815
+ stage="subscription_connecting",
816
+ mode="busy",
817
+ title="Connect AI subscription",
818
+ busy_label="Opening provider sign-in in your browser…",
819
+ )
820
+
821
+ def _screen_subscription_disconnecting(self) -> Screen:
822
+ return Screen(
823
+ stage="subscription_disconnecting",
824
+ mode="busy",
825
+ title="Disconnect AI subscription",
826
+ busy_label="Removing subscription credentials…",
827
+ )
828
+
829
+ def _advanced_summary(self) -> str:
830
+ sub = self._subagent_override_summary()
831
+ forge = _override_summary_text(self.state.forge_role_models)
832
+ if sub == "none" and forge == "none":
833
+ return "per-role model overrides (none set)"
834
+ return f"subagents {sub} · forge {forge}"
835
+
836
+ def _workspace_summary(self) -> str:
837
+ cur = Path(self.current_workspace).name if self.current_workspace else "—"
838
+ default = self.state.default_workspace_path
839
+ if default:
840
+ return f"current: {cur} · default: {Path(default).name}"
841
+ return f"current: {cur} · switch project or set a default"
842
+
843
+ def _subagent_override_summary(self) -> str:
844
+ values = {role: self.state.role_models.get(role, "") for role in ROLE_ORDER}
845
+ if _temperature_controls_available(self.state):
846
+ values.update(
847
+ {
848
+ f"{role}_temperature": value
849
+ for role, value in self.state.role_temperatures.items()
850
+ }
851
+ )
852
+ return _override_summary_text(values)
853
+
854
+ def _screen_advanced(self) -> Screen:
855
+ rows = [
856
+ Row(
857
+ label="Subagent model overrides",
858
+ description=self._subagent_override_summary(),
859
+ value="subagents",
860
+ ),
861
+ Row(
862
+ label="Forge model overrides",
863
+ description=_override_summary_text(self.state.forge_role_models),
864
+ value="forge",
865
+ ),
866
+ Row(label="Back", description="Return to the menu", value="back"),
867
+ ]
868
+ return Screen(
869
+ stage="advanced",
870
+ mode="list",
871
+ title="Advanced",
872
+ subtitle="Per-role model overrides — leave empty to inherit the default model.",
873
+ rows=rows,
874
+ hint="",
875
+ )
876
+
877
+ def _role_override_row(self, role: str, *, model: str, temperature: str = "") -> Row:
878
+ parts = [model or "inherit default"]
879
+ if temperature:
880
+ parts.append(f"temp {temperature}")
881
+ return Row(label=_role_label(role), description=" · ".join(parts), value=role)
882
+
883
+ def _screen_subagent_roles(self) -> Screen:
884
+ rows = [
885
+ self._role_override_row(
886
+ role,
887
+ model=str(self.state.role_models.get(role, "") or ""),
888
+ temperature=str(self.state.role_temperatures.get(role, "") or ""),
889
+ )
890
+ for role in ROLE_ORDER
891
+ ]
892
+ rows.append(Row(label="Back", description="Return to Advanced", value="back"))
893
+ return Screen(
894
+ stage="subagent_roles",
895
+ mode="list",
896
+ title="Subagent model overrides",
897
+ subtitle="Pick a role to edit; roles without an override inherit the default model.",
898
+ rows=rows,
899
+ hint="",
900
+ )
901
+
902
+ def _screen_forge_roles(self) -> Screen:
903
+ rows = [
904
+ self._role_override_row(
905
+ role, model=str(self.state.forge_role_models.get(role, "") or "")
906
+ )
907
+ for role in FORGE_ROLE_ORDER
908
+ ]
909
+ rows.append(Row(label="Back", description="Return to Advanced", value="back"))
910
+ return Screen(
911
+ stage="forge_roles",
912
+ mode="list",
913
+ title="Forge model overrides",
914
+ subtitle="Pick a role to edit; roles without an override inherit the default model.",
915
+ rows=rows,
916
+ hint="",
917
+ )
918
+
919
+ # ----------------------------------------------------------- project / workspace
920
+
921
+ def _workspace_candidates(self) -> list[Any]:
922
+ if self._ws_candidates_cache is None:
923
+ self._ws_candidates_cache = self._discover_workspace_candidates()
924
+ return self._ws_candidates_cache
925
+
926
+ def _discover_workspace_candidates(self) -> list[Any]:
927
+ bases: list[Path] = []
928
+ try:
929
+ bases.append(_suggest_workspace_default())
930
+ except Exception: # noqa: BLE001 - discovery is best-effort
931
+ pass
932
+ if self.current_workspace:
933
+ try:
934
+ parent = Path(self.current_workspace).expanduser().parent
935
+ if parent not in bases:
936
+ bases.append(parent)
937
+ except Exception: # noqa: BLE001
938
+ pass
939
+ out: list[Any] = []
940
+ seen: set[str] = set()
941
+ for base in bases:
942
+ try:
943
+ found = discover_workspace_candidates(base)
944
+ except Exception: # noqa: BLE001
945
+ continue
946
+ for cand in found:
947
+ key = os.fspath(cand.path)
948
+ if key in seen:
949
+ continue
950
+ seen.add(key)
951
+ out.append(cand)
952
+ return out[:8]
953
+
954
+ def _screen_workspace(self) -> Screen:
955
+ rows: list[Row] = []
956
+ for cand in self._workspace_candidates():
957
+ rows.append(
958
+ Row(
959
+ label=cand.path.name or os.fspath(cand.path),
960
+ description=getattr(cand, "summary", "") or os.fspath(cand.path),
961
+ value=os.fspath(cand.path),
962
+ current=os.fspath(cand.path) == self.current_workspace,
963
+ )
964
+ )
965
+ rows.append(
966
+ Row(label="Type a path…", description="Enter any folder path.", value="__type_path__")
967
+ )
968
+ rows.append(Row(label="Back", description="Return to the menu.", value="back"))
969
+ cur = self.current_workspace or "—"
970
+ default = self.state.default_workspace_path or "none"
971
+ return Screen(
972
+ stage="workspace",
973
+ mode="list",
974
+ title="Project / Workspace",
975
+ subtitle=f"Current: {cur} · default: {default}",
976
+ rows=rows,
977
+ hint="",
978
+ )
979
+
980
+ def _screen_workspace_path(self) -> Screen:
981
+ return Screen(
982
+ stage="workspace_path",
983
+ mode="input",
984
+ title="Project / Workspace",
985
+ subtitle="Type the folder you want to work in.",
986
+ input_label="Workspace folder",
987
+ input_default=self.current_workspace,
988
+ hint="Enter next · Esc back",
989
+ )
990
+
991
+ def _screen_workspace_action(self) -> Screen:
992
+ rows = [
993
+ Row(
994
+ label="Switch now — restart chat here",
995
+ description="Save changes and reopen the chat in this project (fresh conversation).",
996
+ value="switch",
997
+ ),
998
+ Row(
999
+ label="Set as default — next launch",
1000
+ description="Use this project next time you start Alysis Code. Current session stays.",
1001
+ value="set_default",
1002
+ ),
1003
+ Row(label="Back", description="Pick a different folder.", value="back"),
1004
+ ]
1005
+ return Screen(
1006
+ stage="workspace_action",
1007
+ mode="list",
1008
+ title="Project / Workspace",
1009
+ subtitle=f"Selected: {self._pending_workspace}",
1010
+ rows=rows,
1011
+ hint="",
1012
+ )
1013
+
1014
+ def _screen_switching(self) -> Screen:
1015
+ return Screen(
1016
+ stage="switching",
1017
+ mode="busy",
1018
+ title="Switching project",
1019
+ busy_label="Saving and switching project…",
1020
+ )
1021
+
1022
+ _PERSONA_ORDER = ("code", "architect", "ask", "debug")
1023
+ _PERSONA_ROWS = (
1024
+ ("code", "Code (default)", "Implementation work; keeps your execution mode."),
1025
+ ("architect", "Architect", "Plans and designs; writes markdown documents only."),
1026
+ ("ask", "Ask", "Read-only questions with inspection tools."),
1027
+ ("debug", "Debug", "Reproduce-before-fix; keeps your execution mode."),
1028
+ )
1029
+
1030
+ def _current_default_persona(self) -> str:
1031
+ persona = str(self.state.fields.get("default_persona") or "code").strip().lower()
1032
+ return persona if persona in self._PERSONA_ORDER else "code"
1033
+
1034
+ def _short_personas(self) -> str:
1035
+ return self._current_default_persona()
1036
+
1037
+ def _persona_index(self) -> int:
1038
+ return self._PERSONA_ORDER.index(self._current_default_persona())
1039
+
1040
+ def _screen_personas(self) -> Screen:
1041
+ current = self._current_default_persona()
1042
+ rows = [
1043
+ Row(label=label, description=description, value=value, current=current == value)
1044
+ for value, label, description in self._PERSONA_ROWS
1045
+ ]
1046
+ return Screen(
1047
+ stage="personas",
1048
+ mode="list",
1049
+ title="Personas",
1050
+ subtitle=(
1051
+ "The persona chat starts in. Switch live with /persona or Tab; "
1052
+ "per-persona model roles via persona_models.<persona>."
1053
+ ),
1054
+ rows=rows,
1055
+ hint="",
1056
+ )
1057
+
1058
+ def _choose_personas(self, value: str) -> None:
1059
+ persona = str(value or "code").strip().lower()
1060
+ if persona not in self._PERSONA_ORDER:
1061
+ persona = "code"
1062
+ self.state.fields["default_persona"] = persona
1063
+ self._goto("menu")
1064
+ self._set_status(f"Default persona: {persona}. Save to apply.", "ok")
1065
+
1066
+ def _screen_sandbox(self) -> Screen:
1067
+ current = normalize_sandbox_mode(self.state.fields.get("sandbox_mode", "strict"))
1068
+ rows = [
1069
+ Row(
1070
+ label="Strict (recommended)",
1071
+ description="Run shell & verification commands in a sandbox (bubblewrap/Docker).",
1072
+ value="strict",
1073
+ current=current == "strict",
1074
+ ),
1075
+ Row(
1076
+ label="Warn",
1077
+ description="Try the sandbox; fail closed if no backend (no host fallback).",
1078
+ value="warn",
1079
+ current=current == "warn",
1080
+ ),
1081
+ Row(
1082
+ label="Off (less safe)",
1083
+ description="Run commands directly on the host shell. No isolation.",
1084
+ value="off",
1085
+ current=current == "off",
1086
+ ),
1087
+ ]
1088
+ subtitle = "How Alysis Code isolates shell and verification commands."
1089
+ override = _sandbox_mode_env_override()
1090
+ if override:
1091
+ subtitle += f" · Note: {override} overrides this at runtime."
1092
+ return Screen(
1093
+ stage="sandbox",
1094
+ mode="list",
1095
+ title="Sandbox",
1096
+ subtitle=subtitle,
1097
+ rows=rows,
1098
+ hint="",
1099
+ )
1100
+
1101
+ def _screen_model(self) -> Screen:
1102
+ current = str(self.state.fields.get("model", "") or "").strip()
1103
+ rows = [
1104
+ Row(
1105
+ label=label,
1106
+ # The "(current)" tag already says it — repeating "current
1107
+ # configured model" as the description is noise.
1108
+ description="" if desc == "current configured model" else desc,
1109
+ value=value,
1110
+ current=value == current,
1111
+ )
1112
+ for value, label, desc in _default_model_rows(self.state)
1113
+ ]
1114
+ subtitle = _default_model_picker_subtitle(self.state)
1115
+ if (
1116
+ self.state.execution_backend == "delegated"
1117
+ and _active_subscription_profile(self.state) is None
1118
+ ):
1119
+ subtitle += (
1120
+ " API-key model setting is preserved but inactive while using an AI subscription."
1121
+ )
1122
+ status = ""
1123
+ status_tone: Tone = "dim"
1124
+ if not rows:
1125
+ # Never render a bare, row-less screen (e.g. subscription mode with
1126
+ # no live catalog and no saved model): explain why it is empty and
1127
+ # leave the custom-name escape hatch selectable.
1128
+ rows = [
1129
+ Row(
1130
+ label="Type a custom model name",
1131
+ description="use a model id you know is available",
1132
+ value=_CUSTOM_MODEL_VALUE,
1133
+ )
1134
+ ]
1135
+ status = "No models advertised yet — sign in first (/login) or type a name."
1136
+ status_tone = "warn"
1137
+ elif self.state._provider_model_catalog_warning:
1138
+ status = self.state._provider_model_catalog_warning
1139
+ status_tone = "warn"
1140
+ return Screen(
1141
+ stage="model",
1142
+ mode="list",
1143
+ title="Default model",
1144
+ subtitle=subtitle,
1145
+ rows=rows,
1146
+ status=status,
1147
+ status_tone=status_tone,
1148
+ hint="",
1149
+ )
1150
+
1151
+ def _screen_custom_model(self) -> Screen:
1152
+ return Screen(
1153
+ stage="custom_model",
1154
+ mode="input",
1155
+ title="Default model",
1156
+ subtitle="Type any model id supported by the active provider.",
1157
+ input_label="Model",
1158
+ input_default=str(self.state.fields.get("model", "")),
1159
+ hint="Enter next · Esc back",
1160
+ )
1161
+
1162
+ def _screen_model_base_url(self) -> Screen:
1163
+ return Screen(
1164
+ stage="model_base_url",
1165
+ mode="input",
1166
+ title="Default model",
1167
+ subtitle="Base URL for the active provider (blank keeps the current value).",
1168
+ input_label="Base URL",
1169
+ input_default=str(self.state.fields.get("base_url", "")),
1170
+ hint="Enter next · Esc back",
1171
+ )
1172
+
1173
+ def _screen_model_thinking(self) -> Screen:
1174
+ current = self.state.thinking_label
1175
+ labels = _thinking_labels_for_state(self.state)
1176
+ preset = _active_preset(self.state)
1177
+ contract = reasoning_contract_for(
1178
+ getattr(preset, "provider_key", None),
1179
+ self.state.fields.get("model", ""),
1180
+ preset_key=getattr(preset, "key", None),
1181
+ )
1182
+ labels = _thinking_labels_allowed_by_contract(contract, labels, current=current)
1183
+ rows = []
1184
+ for label in labels:
1185
+ description = _THINKING_DESCRIPTIONS.get(label, "provider-specific reasoning budget")
1186
+ tone = ""
1187
+ if label.casefold() == "off" and contract.off == OFF_SWAPS_MODEL:
1188
+ # kimi-code: "off" is not a speed knob — a different model answers.
1189
+ description = "silently routes the request to K2.6 — a different model answers"
1190
+ tone = "warn"
1191
+ rows.append(
1192
+ Row(
1193
+ label=label,
1194
+ description=description,
1195
+ value=label,
1196
+ current=label == current,
1197
+ tone=tone,
1198
+ )
1199
+ )
1200
+ subtitle = (
1201
+ "Reasoning effort supported by the selected subscription model."
1202
+ if _active_subscription_profile(self.state) is not None
1203
+ else "Reasoning effort. Some providers ignore this until they add native support."
1204
+ )
1205
+ if preset is not None and preset.key == "nvidia" and contract is UNKNOWN_CONTRACT:
1206
+ subtitle = (
1207
+ "No verified reasoning control is known for this NVIDIA-hosted model; "
1208
+ "Auto sends no guessed parameter."
1209
+ )
1210
+ if contract.mode == ALWAYS_ON:
1211
+ subtitle += " Reasoning cannot be disabled on this model."
1212
+ return Screen(
1213
+ stage="model_thinking",
1214
+ mode="list",
1215
+ title="Default model",
1216
+ subtitle=subtitle,
1217
+ rows=rows,
1218
+ hint="",
1219
+ )
1220
+
1221
+ def _screen_model_timeout(self) -> Screen:
1222
+ return Screen(
1223
+ stage="model_timeout",
1224
+ mode="input",
1225
+ title="Default model",
1226
+ subtitle="How long to wait for a model response.",
1227
+ input_label="Request timeout (seconds)",
1228
+ input_default=str(self.state.fields.get("llm_timeout_s", "")),
1229
+ hint="Enter save · Esc back",
1230
+ )
1231
+
1232
+ def _screen_web_search_policy(self) -> Screen:
1233
+ current = normalize_web_search_policy(self.state.fields.get("web_search_policy", "auto"))
1234
+ return Screen(
1235
+ stage="web_search_policy",
1236
+ mode="list",
1237
+ title="Web Search Access",
1238
+ subtitle="Whether the active model can decide to search the web.",
1239
+ rows=[
1240
+ Row(label=label, description=description, value=value, current=value == current)
1241
+ for value, label, description in _web_search_policy_rows()
1242
+ ],
1243
+ hint="",
1244
+ )
1245
+
1246
+ def _screen_web_search_mode(self) -> Screen:
1247
+ current = _normalize_web_search_mode(self.state.fields.get("web_search_mode", "auto"))
1248
+ return Screen(
1249
+ stage="web_search_mode",
1250
+ mode="list",
1251
+ title="Web Search Backend",
1252
+ subtitle="Which provider executes searches selected by the model.",
1253
+ rows=[
1254
+ Row(label=label, description=description, value=value, current=value == current)
1255
+ for value, label, description in _web_search_mode_rows()
1256
+ ],
1257
+ hint="",
1258
+ )
1259
+
1260
+ def _screen_cache_mode(self) -> Screen:
1261
+ current = _normalize_prompt_cache_mode(self.state.fields.get("prompt_cache_mode", "manual"))
1262
+ rows = [
1263
+ Row(
1264
+ label=value.capitalize(),
1265
+ description=_CACHE_MODE_DESCRIPTIONS[value],
1266
+ value=value,
1267
+ current=value == current,
1268
+ )
1269
+ for value in PROMPT_CACHE_MODES
1270
+ ]
1271
+ return Screen(
1272
+ stage="cache_mode",
1273
+ mode="list",
1274
+ title="Context & Cache",
1275
+ subtitle=_cache_mode_subtitle(self.state),
1276
+ rows=rows,
1277
+ hint="",
1278
+ )
1279
+
1280
+ def _screen_cache_key(self) -> Screen:
1281
+ return Screen(
1282
+ stage="cache_key",
1283
+ mode="input",
1284
+ title="Context & Cache",
1285
+ subtitle="Manual key for providers that accept prompt_cache_key.",
1286
+ input_label='Manual cache key ("clear" to unset)',
1287
+ input_default=str(self.state.fields.get("prompt_cache_key", "")),
1288
+ hint="Enter next · Esc back",
1289
+ )
1290
+
1291
+ def _screen_cache_retention(self) -> Screen:
1292
+ return Screen(
1293
+ stage="cache_retention",
1294
+ mode="input",
1295
+ title="Context & Cache",
1296
+ subtitle="Optional provider retention hint, for example 24h.",
1297
+ input_label='Manual cache retention ("clear" to unset)',
1298
+ input_default=str(self.state.fields.get("prompt_cache_retention", "")),
1299
+ hint="Enter next · Esc back",
1300
+ )
1301
+
1302
+ def _screen_cache_anthropic_enabled(self) -> Screen:
1303
+ current = _normalize_bool_text(
1304
+ self.state.fields.get("anthropic_prompt_cache_enabled", "false"),
1305
+ label="Anthropic prompt cache",
1306
+ )
1307
+ rows = [
1308
+ Row(
1309
+ label="On",
1310
+ description="Enable Anthropic cache_control in manual mode.",
1311
+ value="true",
1312
+ current=current is True,
1313
+ ),
1314
+ Row(
1315
+ label="Off",
1316
+ description="Only auto mode enables Anthropic cache_control.",
1317
+ value="false",
1318
+ current=current is False,
1319
+ ),
1320
+ ]
1321
+ return Screen(
1322
+ stage="cache_anthropic_enabled",
1323
+ mode="list",
1324
+ title="Context & Cache",
1325
+ subtitle="Anthropic cache_control override.",
1326
+ rows=rows,
1327
+ hint="",
1328
+ )
1329
+
1330
+ def _screen_cache_anthropic_ttl(self) -> Screen:
1331
+ current = _normalize_anthropic_prompt_cache_ttl(
1332
+ self.state.fields.get("anthropic_prompt_cache_ttl", "5m")
1333
+ )
1334
+ rows = [
1335
+ Row(
1336
+ label=value,
1337
+ description=_CACHE_TTL_DESCRIPTIONS[value],
1338
+ value=value,
1339
+ current=value == current,
1340
+ )
1341
+ for value in ANTHROPIC_PROMPT_CACHE_TTLS
1342
+ ]
1343
+ return Screen(
1344
+ stage="cache_anthropic_ttl",
1345
+ mode="list",
1346
+ title="Context & Cache",
1347
+ subtitle="Anthropic cache_control TTL.",
1348
+ rows=rows,
1349
+ hint="",
1350
+ )
1351
+
1352
+ def _screen_cache_compaction_enabled(self) -> Screen:
1353
+ current = _normalize_bool_text(
1354
+ self.state.fields.get("cache_aware_compaction", "true"),
1355
+ label="Cache-aware compaction",
1356
+ )
1357
+ rows = [
1358
+ Row(
1359
+ label="On",
1360
+ description="Compact earlier when the next request is likely to miss provider cache.",
1361
+ value="true",
1362
+ current=current is True,
1363
+ ),
1364
+ Row(
1365
+ label="Off",
1366
+ description="Use only the normal compaction trigger ratio.",
1367
+ value="false",
1368
+ current=current is False,
1369
+ ),
1370
+ ]
1371
+ return Screen(
1372
+ stage="cache_compaction_enabled",
1373
+ mode="list",
1374
+ title="Context & Cache",
1375
+ subtitle="Cache-aware compaction trigger.",
1376
+ rows=rows,
1377
+ hint="",
1378
+ )
1379
+
1380
+ def _screen_cache_compaction_min_trigger(self) -> Screen:
1381
+ return Screen(
1382
+ stage="cache_compaction_min_trigger",
1383
+ mode="input",
1384
+ title="Context & Cache",
1385
+ subtitle="Lowest trigger ratio cache-aware compaction may use.",
1386
+ input_label="Cache-aware min trigger ratio",
1387
+ input_default=str(self.state.fields.get("cache_aware_min_trigger_ratio", "")),
1388
+ hint="Enter save · Esc back",
1389
+ )
1390
+
1391
+ def _screen_subagent_field(self) -> Screen:
1392
+ role, kind = self._sub_steps[self._sub_i]
1393
+ label = _role_label(role)
1394
+ if kind == "model":
1395
+ field_label = f'{label} model (blank keeps · "clear" inherits)'
1396
+ default = self.state.role_models.get(role, "")
1397
+ else:
1398
+ field_label = f'{label} temperature (blank keeps · "clear" inherits)'
1399
+ default = self.state.role_temperatures.get(role, "")
1400
+ subtitle = _role_description(role)
1401
+ if len(self._sub_steps) > 1:
1402
+ subtitle += f" · step {self._sub_i + 1}/{len(self._sub_steps)}"
1403
+ if not _temperature_controls_available(self.state):
1404
+ subtitle += " · temperature is managed by the AI subscription"
1405
+ return Screen(
1406
+ stage="subagent_field",
1407
+ mode="input",
1408
+ title="Subagent model overrides",
1409
+ subtitle=subtitle,
1410
+ input_label=field_label,
1411
+ input_default=default,
1412
+ hint="Enter next · Esc cancel section",
1413
+ )
1414
+
1415
+ def _screen_forge_field(self) -> Screen:
1416
+ role = self._forge_role or FORGE_ROLE_ORDER[0]
1417
+ return Screen(
1418
+ stage="forge_field",
1419
+ mode="input",
1420
+ title="Forge model overrides",
1421
+ subtitle=_role_description(role),
1422
+ input_label=f'{_role_label(role)} model (blank keeps · "clear" inherits)',
1423
+ input_default=self.state.forge_role_models.get(role, ""),
1424
+ hint="Enter save · Esc cancel",
1425
+ )
1426
+
1427
+ def _screen_api_key(self) -> Screen:
1428
+ subtitle = (
1429
+ f"Stored: {self.state.masked_api_key} · {_pretty_key_source(self.state.api_key_source)}"
1430
+ )
1431
+ staged_profile = self.state.staged_api_key_target_profile()
1432
+ if staged_profile and staged_profile != (self.state.active_profile or None):
1433
+ subtitle += f"; unsaved key remains bound to profile {staged_profile}"
1434
+ clear_profile = self.state.clear_stored_key_profile
1435
+ if (
1436
+ self.state.clear_stored_key_confirmed
1437
+ and clear_profile
1438
+ and clear_profile != (self.state.active_profile or None)
1439
+ ):
1440
+ subtitle += f"; stored-key removal remains bound to profile {clear_profile}"
1441
+ if self.state.execution_backend == "delegated":
1442
+ subtitle += " · API key is preserved but inactive while using an AI subscription."
1443
+ return Screen(
1444
+ stage="api_key",
1445
+ mode="input",
1446
+ title="API key",
1447
+ subtitle=subtitle,
1448
+ input_label='New API key (blank keeps current · "clear" to remove)',
1449
+ input_password=True,
1450
+ hint="Enter save · Esc back",
1451
+ )
1452
+
1453
+ def _screen_api_key_clear_confirm(self) -> Screen:
1454
+ return Screen(
1455
+ stage="api_key_clear_confirm",
1456
+ mode="confirm",
1457
+ title="API key",
1458
+ lines=[("Clear the stored API key?", "warn")],
1459
+ hint="Y clear · N keep · Esc back",
1460
+ confirm_default=False,
1461
+ )
1462
+
1463
+ def _screen_provider(self) -> Screen:
1464
+ active = self.state.active_profile or "(none)"
1465
+ rows = [
1466
+ Row(label=label, description=desc, value=value)
1467
+ for value, label, desc in _PROVIDER_ACTION_ROWS
1468
+ ]
1469
+ subtitle = f"Active profile: {active}"
1470
+ if self.state.execution_backend == "delegated":
1471
+ subtitle += " · API-key provider settings are preserved but inactive."
1472
+ return Screen(
1473
+ stage="provider",
1474
+ mode="list",
1475
+ title="Provider profile",
1476
+ subtitle=subtitle,
1477
+ rows=rows,
1478
+ hint="",
1479
+ )
1480
+
1481
+ def _screen_provider_switch(self) -> Screen:
1482
+ rows = [
1483
+ Row(
1484
+ label=name,
1485
+ description="active" if name == self.state.active_profile else "",
1486
+ value=name,
1487
+ current=name == self.state.active_profile,
1488
+ )
1489
+ for name in sorted(self.state.profiles)
1490
+ ]
1491
+ return Screen(
1492
+ stage="provider_switch",
1493
+ mode="list",
1494
+ title="Switch profile",
1495
+ subtitle=f"Active: {self.state.active_profile or 'none'}",
1496
+ rows=rows,
1497
+ hint="",
1498
+ )
1499
+
1500
+ def _screen_provider_add_preset(self) -> Screen:
1501
+ rows = [
1502
+ Row(
1503
+ label=_short_preset_label(preset),
1504
+ description=_short_preset_description(preset),
1505
+ value=preset.key,
1506
+ )
1507
+ for preset in _ordered_profile_presets_for_setup()
1508
+ ]
1509
+ rows.append(
1510
+ Row(
1511
+ label="Advanced / local / compatibility providers",
1512
+ description="Ollama, LM Studio, vLLM, custom URLs",
1513
+ value=_ADVANCED_PROVIDER_PRESETS_VALUE,
1514
+ )
1515
+ )
1516
+ return Screen(
1517
+ stage="provider_add_preset",
1518
+ mode="list",
1519
+ title="Add provider preset",
1520
+ subtitle="Pick a provider preset.",
1521
+ rows=rows,
1522
+ hint="",
1523
+ )
1524
+
1525
+ def _screen_provider_add_preset_advanced(self) -> Screen:
1526
+ rows = [
1527
+ Row(
1528
+ label=_short_preset_label(preset),
1529
+ description=_short_preset_description(preset),
1530
+ value=preset.key,
1531
+ )
1532
+ for preset in _advanced_profile_presets_for_setup()
1533
+ ]
1534
+ return Screen(
1535
+ stage="provider_add_preset_advanced",
1536
+ mode="list",
1537
+ title="Advanced provider preset",
1538
+ subtitle="Compatibility, local, custom, legacy, and account sign-in providers.",
1539
+ rows=rows,
1540
+ hint="",
1541
+ )
1542
+
1543
+ def _screen_provider_preset_name(self) -> Screen:
1544
+ return Screen(
1545
+ stage="provider_preset_name",
1546
+ mode="input",
1547
+ title="Add provider preset",
1548
+ subtitle="Name this profile (lowercase, no spaces).",
1549
+ input_label="Profile name",
1550
+ input_default=getattr(self._pending_preset, "key", "custom"),
1551
+ hint="Enter next · Esc back",
1552
+ )
1553
+
1554
+ def _screen_provider_preset_url(self) -> Screen:
1555
+ return Screen(
1556
+ stage="provider_preset_url",
1557
+ mode="input",
1558
+ title="Add provider preset",
1559
+ subtitle="This preset needs an OpenAI-compatible base URL.",
1560
+ input_label="Base URL",
1561
+ input_default="",
1562
+ hint="Enter next · Esc back",
1563
+ )
1564
+
1565
+ def _screen_provider_custom_name(self) -> Screen:
1566
+ return Screen(
1567
+ stage="provider_custom_name",
1568
+ mode="input",
1569
+ title="Add custom profile",
1570
+ subtitle="Name this profile (lowercase, no spaces).",
1571
+ input_label="Profile name",
1572
+ input_default=self._custom_name or "custom",
1573
+ hint="Enter next · Esc back",
1574
+ )
1575
+
1576
+ def _screen_provider_custom_url(self) -> Screen:
1577
+ return Screen(
1578
+ stage="provider_custom_url",
1579
+ mode="input",
1580
+ title="Add custom profile",
1581
+ subtitle="The OpenAI-compatible base URL for this provider.",
1582
+ input_label="Base URL",
1583
+ input_default=self._custom_url,
1584
+ hint="Enter next · Esc back",
1585
+ )
1586
+
1587
+ def _screen_provider_custom_headers(self) -> Screen:
1588
+ return Screen(
1589
+ stage="provider_custom_headers",
1590
+ mode="input",
1591
+ title="Add custom profile",
1592
+ subtitle="Extra request headers, if your endpoint needs them.",
1593
+ input_label="Extra headers (k=v, comma-separated)",
1594
+ input_default="",
1595
+ hint="Enter add · Esc back",
1596
+ )
1597
+
1598
+ def _screen_provider_edit(self) -> Screen:
1599
+ field = _EDIT_FIELDS[self._edit_i]
1600
+ name = getattr(self._edit_profile, "name", "")
1601
+ return Screen(
1602
+ stage="provider_edit",
1603
+ mode="input",
1604
+ title=f"Edit profile: {name}",
1605
+ subtitle=f"Blank keeps the current value · step {self._edit_i + 1}/{len(_EDIT_FIELDS)}",
1606
+ input_label=_EDIT_LABELS[field],
1607
+ input_default=self._edit_values.get(field, ""),
1608
+ hint="Enter next · Esc cancel",
1609
+ )
1610
+
1611
+ def _screen_provider_remove(self) -> Screen:
1612
+ rows = [Row(label=name, value=name) for name in sorted(self.state.profiles)]
1613
+ return Screen(
1614
+ stage="provider_remove",
1615
+ mode="list",
1616
+ title="Remove profile",
1617
+ subtitle="Pick a profile to remove (applied on save).",
1618
+ rows=rows,
1619
+ hint="",
1620
+ )
1621
+
1622
+ def _screen_provider_remove_confirm(self) -> Screen:
1623
+ return Screen(
1624
+ stage="provider_remove_confirm",
1625
+ mode="confirm",
1626
+ title="Remove profile",
1627
+ lines=[(f"Remove profile {self._pending_remove}?", "warn")],
1628
+ hint="Y remove · N keep · Esc back",
1629
+ confirm_default=False,
1630
+ )
1631
+
1632
+ def _screen_saving(self) -> Screen:
1633
+ return Screen(
1634
+ stage="saving", mode="busy", title="Saving", busy_label="Saving configuration…"
1635
+ )
1636
+
1637
+ def _screen_cancel_confirm(self) -> Screen:
1638
+ return Screen(
1639
+ stage="cancel_confirm",
1640
+ mode="confirm",
1641
+ title="Discard changes",
1642
+ lines=[("Discard pending changes and close?", "warn")],
1643
+ hint="Y discard · N keep editing",
1644
+ confirm_default=False,
1645
+ )
1646
+
1647
+ def _screen_done(self) -> Screen:
1648
+ return Screen(stage="done", mode="done", title="")
1649
+
1650
+ # ----------------------------------------------------------- navigation
1651
+
1652
+ def move(self, delta: int) -> None:
1653
+ scr = self.screen()
1654
+ if scr.mode != "list" or not scr.rows:
1655
+ return
1656
+ # Step over header/spacer rows so the cursor only ever rests on items.
1657
+ n = len(scr.rows)
1658
+ i = self.index
1659
+ for _ in range(n):
1660
+ i = (i + delta) % n
1661
+ if getattr(scr.rows[i], "kind", "item") == "item":
1662
+ break
1663
+ self.index = i
1664
+
1665
+ def choose_index(self, idx: int) -> None:
1666
+ scr = self.screen()
1667
+ if scr.mode != "list" or not scr.rows:
1668
+ return
1669
+ if not getattr(scr, "numbered", True):
1670
+ # Unnumbered lists (the grouped menu) advertise no digit shortcuts.
1671
+ return
1672
+ if 0 <= idx < len(scr.rows):
1673
+ self.index = idx
1674
+ self.choose(scr.rows[idx].value)
1675
+
1676
+ def choose_current(self) -> None:
1677
+ scr = self.screen()
1678
+ if scr.mode != "list" or not scr.rows:
1679
+ return
1680
+ row = scr.rows[self.index]
1681
+ if getattr(row, "kind", "item") != "item":
1682
+ return
1683
+ self.choose(row.value)
1684
+
1685
+ def choose(self, value: str) -> None:
1686
+ handler = {
1687
+ "menu": self._choose_menu,
1688
+ "execution_backend": self._choose_execution_backend,
1689
+ "execution_runtime": self._choose_execution_runtime,
1690
+ "subscription_account": self._choose_subscription_account,
1691
+ "advanced": self._choose_advanced,
1692
+ "subagent_roles": self._choose_subagent_roles,
1693
+ "forge_roles": self._choose_forge_roles,
1694
+ "workspace": self._choose_workspace,
1695
+ "workspace_action": self._choose_workspace_action,
1696
+ "sandbox": self._choose_sandbox,
1697
+ "personas": self._choose_personas,
1698
+ "model": self._choose_model,
1699
+ "model_thinking": self._choose_thinking,
1700
+ "web_search_policy": self._choose_web_search_policy,
1701
+ "web_search_mode": self._choose_web_search_mode,
1702
+ "cache_mode": self._choose_cache_mode,
1703
+ "cache_anthropic_enabled": self._choose_cache_anthropic_enabled,
1704
+ "cache_anthropic_ttl": self._choose_cache_anthropic_ttl,
1705
+ "cache_compaction_enabled": self._choose_cache_compaction_enabled,
1706
+ "provider": self._choose_provider_action,
1707
+ "provider_switch": self._choose_provider_switch,
1708
+ "provider_add_preset": self._choose_preset,
1709
+ "provider_add_preset_advanced": self._choose_preset,
1710
+ "provider_remove": self._choose_remove,
1711
+ }.get(self.stage)
1712
+ if handler is not None:
1713
+ handler(value)
1714
+
1715
+ def submit_input(self, text: str) -> None:
1716
+ handler = getattr(self, f"_submit_{self.stage}", None)
1717
+ if handler is not None:
1718
+ handler(str(text))
1719
+
1720
+ def confirm(self, yes: bool) -> None:
1721
+ if self.stage == "api_key_clear_confirm":
1722
+ if yes:
1723
+ self.state.mark_clear_stored_key_confirmed()
1724
+ self._goto("menu")
1725
+ self._set_status("Stored API key will be cleared on save.", "warn")
1726
+ else:
1727
+ self._goto("menu")
1728
+ elif self.stage == "provider_remove_confirm":
1729
+ if yes:
1730
+ staged_key_discarded = self.state.remove_profile_name(self._pending_remove)
1731
+ self._goto("provider")
1732
+ message = f"Profile {self._pending_remove} will be removed on save."
1733
+ if staged_key_discarded:
1734
+ message += " Its unsaved API key was discarded."
1735
+ self._set_status(message, "warn")
1736
+ else:
1737
+ self._goto("provider")
1738
+ elif self.stage == "subscription_disconnect_confirm":
1739
+ if yes:
1740
+ self._goto("subscription_disconnecting")
1741
+ else:
1742
+ self._goto("subscription_account")
1743
+ elif self.stage == "cancel_confirm":
1744
+ if yes:
1745
+ self._finish(False)
1746
+ else:
1747
+ self._goto(self._resume_stage or "menu")
1748
+
1749
+ def back(self) -> None:
1750
+ if self.stage == "menu":
1751
+ self.request_cancel()
1752
+ return
1753
+ if self.stage == "subagent_field":
1754
+ self.state.role_models = dict(self._sub_model_snapshot)
1755
+ self.state.role_temperatures = dict(self._sub_temp_snapshot)
1756
+ self._goto("subagent_roles")
1757
+ return
1758
+ if self.stage == "forge_field":
1759
+ self.state.forge_role_models = dict(self._forge_snapshot)
1760
+ self._goto("forge_roles")
1761
+ return
1762
+ if self.stage in {"subagent_roles", "forge_roles"}:
1763
+ self._goto("advanced")
1764
+ return
1765
+ if self.stage == "cancel_confirm":
1766
+ self._goto(self._resume_stage or "menu")
1767
+ return
1768
+ prev = _PREV.get(self.stage)
1769
+ if prev is not None:
1770
+ self._goto(prev)
1771
+
1772
+ def request_cancel(self) -> None:
1773
+ """Ctrl+C / the Discard row: confirm before discarding when dirty."""
1774
+ if self.stage in {
1775
+ "cancel_confirm",
1776
+ "done",
1777
+ "saving",
1778
+ "subscription_connecting",
1779
+ "subscription_disconnecting",
1780
+ }:
1781
+ return
1782
+ if self.state.dirty:
1783
+ # Answering "No" returns to wherever the user was (a sub-flow field, a
1784
+ # section list, or the menu) rather than always snapping back to the top.
1785
+ self._resume_stage = self.stage
1786
+ self._goto("cancel_confirm", keep_status=True)
1787
+ else:
1788
+ self._finish(False)
1789
+
1790
+ # ------------------------------------------------------------- menu step
1791
+
1792
+ def _choose_menu(self, value: str) -> None:
1793
+ # Any return to the top menu ends an in-progress add-provider chain.
1794
+ self._preset_setup_chain = False
1795
+ # Remember where the cursor was so Esc-ing back out of a section lands
1796
+ # on the row the user came from, not back at the top.
1797
+ self._menu_resume_index = self.index
1798
+ if value == _SAVE:
1799
+ self._goto("saving")
1800
+ return
1801
+ if value == _CANCEL:
1802
+ self.request_cancel()
1803
+ return
1804
+ if value == "execution":
1805
+ self._goto("execution_backend", index=self._execution_backend_index())
1806
+ elif value == "profile":
1807
+ self._goto("provider")
1808
+ elif value == "api_key":
1809
+ self._goto("api_key")
1810
+ elif value == "default":
1811
+ self.open_default_model()
1812
+ elif value == "web_search":
1813
+ self._goto("web_search_policy", index=self._web_search_policy_index())
1814
+ elif value == "cache":
1815
+ self._goto("cache_mode", index=self._cache_mode_index())
1816
+ elif value == "workspace":
1817
+ self._goto("workspace")
1818
+ elif value == "advanced":
1819
+ self._goto("advanced")
1820
+ elif value == "sandbox":
1821
+ self._goto("sandbox", index=self._sandbox_index())
1822
+ elif value == "personas":
1823
+ self._goto("personas", index=self._persona_index())
1824
+
1825
+ def _execution_backend_index(self) -> int:
1826
+ return 1 if self.state.execution_backend == "delegated" else 0
1827
+
1828
+ def _choose_execution_backend(self, value: str) -> None:
1829
+ if value == "native":
1830
+ self.state.set_execution_backend("native")
1831
+ self._goto("menu")
1832
+ self._set_status("API-key model access selected. Save to apply.", "ok")
1833
+ return
1834
+ if value != "delegated":
1835
+ self._set_status(f"Unknown model access method: {value}", "err")
1836
+ return
1837
+ rows = _runtime_setup_rows()
1838
+ if not rows:
1839
+ self._set_status("No AI subscription connections are available in this build.", "warn")
1840
+ return
1841
+ runtime_ids = [runtime_id for runtime_id, _label, _description in rows]
1842
+ index = (
1843
+ runtime_ids.index(self.state.execution_runtime)
1844
+ if self.state.execution_runtime in runtime_ids
1845
+ else 0
1846
+ )
1847
+ self._goto("execution_runtime", index=index)
1848
+
1849
+ def _choose_execution_runtime(self, value: str) -> None:
1850
+ if value == _ALYSIS_CONNECTION_ID:
1851
+ # Not a delegated runtime: the account screen drives the device
1852
+ # login, which activates the native alysis profile itself.
1853
+ self._subscription_provider_id = _ALYSIS_CONNECTION_ID
1854
+ self._subscription_account_status = None
1855
+ self._goto("subscription_account")
1856
+ return
1857
+ known = {runtime_id for runtime_id, _label, _description in _runtime_setup_rows()}
1858
+ if value not in known:
1859
+ self._set_status(f"Unknown AI subscription connection: {value}", "err")
1860
+ return
1861
+ self.state.set_execution_backend("delegated", runtime=value)
1862
+ self._subscription_provider_id = value
1863
+ self._subscription_account_status = None
1864
+ self._goto("subscription_account")
1865
+
1866
+ def _choose_subscription_account(self, value: str) -> None:
1867
+ if value == "chat":
1868
+ # Alysis Code connect already persisted everything (self.saved is
1869
+ # set), so finishing here closes the overlay, reloads the live
1870
+ # session with the new key, and drops back into chat.
1871
+ self._finish(True)
1872
+ return
1873
+ if value == "back":
1874
+ self._goto("menu")
1875
+ return
1876
+ if value == "connect":
1877
+ status = (
1878
+ self._subscription_account_status or self._refresh_subscription_account_status()
1879
+ )
1880
+ self._subscription_switch_account = bool(status.connected)
1881
+ self._goto("subscription_connecting")
1882
+ return
1883
+ if value == "disconnect":
1884
+ self._goto("subscription_disconnect_confirm")
1885
+ return
1886
+ self._set_status(f"Unknown subscription account action: {value}", "err")
1887
+
1888
+ def _choose_advanced(self, value: str) -> None:
1889
+ if value == "back":
1890
+ self._goto("menu")
1891
+ elif value == "subagents":
1892
+ self._goto("subagent_roles")
1893
+ elif value == "forge":
1894
+ self._goto("forge_roles")
1895
+
1896
+ def _choose_subagent_roles(self, value: str) -> None:
1897
+ if value == "back":
1898
+ self._goto("advanced")
1899
+ return
1900
+ # Edit exactly the picked role (model, plus temperature where the
1901
+ # provider exposes it) — never a forced walk through every role.
1902
+ self._sub_model_snapshot = dict(self.state.role_models)
1903
+ self._sub_temp_snapshot = dict(self.state.role_temperatures)
1904
+ steps: list[tuple[str, str]] = [(value, "model")]
1905
+ if _temperature_controls_available(self.state) and value in _ROLE_TEMPERATURE_FIELDS:
1906
+ steps.append((value, "temp"))
1907
+ self._sub_steps = steps
1908
+ self._sub_i = 0
1909
+ self._goto("subagent_field")
1910
+
1911
+ def _choose_forge_roles(self, value: str) -> None:
1912
+ if value == "back":
1913
+ self._goto("advanced")
1914
+ return
1915
+ self._forge_snapshot = dict(self.state.forge_role_models)
1916
+ self._forge_role = value
1917
+ self._goto("forge_field")
1918
+
1919
+ # ----------------------------------------------------- project / workspace
1920
+
1921
+ def _resolve_workspace(self, raw: str) -> str | None:
1922
+ text = str(raw).strip()
1923
+ if not text:
1924
+ self._set_status("A folder path is required.", "err")
1925
+ return None
1926
+ try:
1927
+ selected = Path(text).expanduser().resolve()
1928
+ binding = resolve_workspace_binding(
1929
+ selected, allow_broad_workspace=True, source="config_menu"
1930
+ )
1931
+ except WorkspaceBindingError as exc:
1932
+ self._set_status(str(exc), "err")
1933
+ return None
1934
+ except Exception as exc: # noqa: BLE001 - bad path / permission error
1935
+ self._set_status(f"Invalid path: {exc}", "err")
1936
+ return None
1937
+ return os.fspath(binding.requested_path)
1938
+
1939
+ def _choose_workspace(self, value: str) -> None:
1940
+ if value == "back":
1941
+ self._goto("menu")
1942
+ return
1943
+ if value == "__type_path__":
1944
+ self._goto("workspace_path")
1945
+ return
1946
+ resolved = self._resolve_workspace(value)
1947
+ if resolved is None:
1948
+ return
1949
+ self._pending_workspace = resolved
1950
+ self._goto("workspace_action")
1951
+
1952
+ def _submit_workspace_path(self, text: str) -> None:
1953
+ resolved = self._resolve_workspace(text)
1954
+ if resolved is None:
1955
+ return
1956
+ self._pending_workspace = resolved
1957
+ self._goto("workspace_action")
1958
+
1959
+ def _choose_workspace_action(self, value: str) -> None:
1960
+ if value == "back" or not self._pending_workspace:
1961
+ self._goto("workspace")
1962
+ return
1963
+ if value == "set_default":
1964
+ self.state.set_default_workspace_path(self._pending_workspace)
1965
+ self._goto("menu")
1966
+ self._set_status("Default project set. Save to apply.", "ok")
1967
+ return
1968
+ if value == "switch":
1969
+ # Persist the new default + all pending edits, then signal the host to
1970
+ # relaunch the chat bound to this folder (the live root cannot change in
1971
+ # place). The full save runs on the busy stage; on success
1972
+ # apply_save_outcome promotes _switch_target -> switch_workspace.
1973
+ self.state.set_default_workspace_path(self._pending_workspace)
1974
+ self._switch_target = self._pending_workspace
1975
+ self._goto("switching")
1976
+
1977
+ def _sandbox_index(self) -> int:
1978
+ order = ("strict", "warn", "off")
1979
+ current = normalize_sandbox_mode(self.state.fields.get("sandbox_mode", "strict"))
1980
+ return order.index(current) if current in order else 0
1981
+
1982
+ # ----------------------------------------------------------- sandbox
1983
+
1984
+ def _choose_sandbox(self, value: str) -> None:
1985
+ normalized = normalize_sandbox_mode(value)
1986
+ self.state.fields["sandbox_mode"] = normalized
1987
+ self._goto("menu")
1988
+ if normalized == "off":
1989
+ self._set_status("Sandbox off — commands run on the host shell. Save to apply.", "warn")
1990
+ else:
1991
+ self._set_status(f"Sandbox mode: {normalized}. Save to apply.", "ok")
1992
+
1993
+ # ----------------------------------------------------------- default model
1994
+
1995
+ def _model_index(self) -> int:
1996
+ rows = _default_model_rows(self.state)
1997
+ order = [value for value, _label, _desc in rows]
1998
+ current = str(self.state.fields.get("model", "") or "").strip()
1999
+ if current in order:
2000
+ return order.index(current)
2001
+ if current and _CUSTOM_MODEL_VALUE in order:
2002
+ return order.index(_CUSTOM_MODEL_VALUE)
2003
+ return 0
2004
+
2005
+ def _choose_model(self, value: str) -> None:
2006
+ if value == _CUSTOM_MODEL_VALUE:
2007
+ self._goto("custom_model")
2008
+ return
2009
+ self.state.set_field("model", value)
2010
+ if self._finish_preset_chain_if_active(model=value):
2011
+ return
2012
+ if _active_subscription_profile(self.state) is not None:
2013
+ labels = _thinking_labels_for_state(self.state, model=value)
2014
+ if self.state.thinking_label not in labels:
2015
+ self.state.set_thinking_label("auto")
2016
+ self._goto("model_thinking", index=self._thinking_index())
2017
+ return
2018
+ self._goto("model_base_url")
2019
+
2020
+ def _submit_custom_model(self, text: str) -> None:
2021
+ model = text.strip() or str(self.state.fields.get("model", "")).strip()
2022
+ if not model:
2023
+ self._set_status("Model is required.", "err")
2024
+ return
2025
+ self.state.set_field("model", model)
2026
+ if self._finish_preset_chain_if_active(model=model):
2027
+ return
2028
+ self._goto("model_base_url")
2029
+
2030
+ def _submit_model_base_url(self, text: str) -> None:
2031
+ value = text.strip() or str(self.state.fields.get("base_url", ""))
2032
+ try:
2033
+ validate_base_url(value, key="Base URL", allow_empty=True)
2034
+ except ConfigError as exc:
2035
+ self._set_status(str(exc), "err")
2036
+ return
2037
+ self.state.set_field("base_url", value)
2038
+ self._goto("model_thinking", index=self._thinking_index())
2039
+
2040
+ def _thinking_index(self) -> int:
2041
+ current = self.state.thinking_label
2042
+ labels = _thinking_labels_for_state(self.state)
2043
+ return labels.index(current) if current in labels else 0
2044
+
2045
+ def _choose_thinking(self, value: str) -> None:
2046
+ self.state.set_thinking_label(value)
2047
+ if self._preset_setup_chain:
2048
+ self._complete_preset_setup_chain(
2049
+ model=str(self.state.fields.get("model", "") or "").strip()
2050
+ )
2051
+ return
2052
+ self._goto("model_timeout")
2053
+
2054
+ def _submit_model_timeout(self, text: str) -> None:
2055
+ value = text.strip() or str(self.state.fields.get("llm_timeout_s", ""))
2056
+ number = _finite_float(value, fallback=None)
2057
+ if number is None or number <= 0:
2058
+ self._set_status("Request timeout must be a positive number.", "err")
2059
+ return
2060
+ self.state.set_field("llm_timeout_s", _format_number(number))
2061
+ self._goto("menu")
2062
+ self._set_status("Default model updated. Save to apply.", "ok")
2063
+
2064
+ # --------------------------------------------------------------- web search
2065
+
2066
+ def _choose_web_search_policy(self, value: str) -> None:
2067
+ self.state.set_field("web_search_policy", normalize_web_search_policy(value))
2068
+ self._goto("web_search_mode", index=self._web_search_mode_index())
2069
+
2070
+ def _choose_web_search_mode(self, value: str) -> None:
2071
+ self.state.set_field("web_search_mode", _normalize_web_search_mode(value))
2072
+ self._goto("menu")
2073
+ self._set_status("Web search settings updated. Save to apply.", "ok")
2074
+
2075
+ def _web_search_policy_index(self) -> int:
2076
+ current = normalize_web_search_policy(self.state.fields.get("web_search_policy", "auto"))
2077
+ order = [value for value, _label, _description in _web_search_policy_rows()]
2078
+ return order.index(current) if current in order else 0
2079
+
2080
+ def _web_search_mode_index(self) -> int:
2081
+ current = _normalize_web_search_mode(self.state.fields.get("web_search_mode", "auto"))
2082
+ order = [value for value, _label, _description in _web_search_mode_rows()]
2083
+ return order.index(current) if current in order else 0
2084
+
2085
+ # ----------------------------------------------------------- context & cache
2086
+
2087
+ def _choose_cache_mode(self, value: str) -> None:
2088
+ self.state.set_field("prompt_cache_mode", _normalize_prompt_cache_mode(value))
2089
+ self._goto("cache_key")
2090
+
2091
+ def _submit_cache_key(self, text: str) -> None:
2092
+ value = self._optional_text_value(text, self.state.fields.get("prompt_cache_key", ""))
2093
+ self.state.set_field("prompt_cache_key", value)
2094
+ self._goto("cache_retention")
2095
+
2096
+ def _submit_cache_retention(self, text: str) -> None:
2097
+ value = self._optional_text_value(
2098
+ text,
2099
+ self.state.fields.get("prompt_cache_retention", ""),
2100
+ )
2101
+ self.state.set_field("prompt_cache_retention", value)
2102
+ self._goto(
2103
+ "cache_anthropic_enabled",
2104
+ index=self._cache_anthropic_enabled_index(),
2105
+ )
2106
+
2107
+ def _choose_cache_anthropic_enabled(self, value: str) -> None:
2108
+ enabled = _normalize_bool_text(value, label="Anthropic prompt cache")
2109
+ self.state.set_field("anthropic_prompt_cache_enabled", "true" if enabled else "false")
2110
+ self._goto("cache_anthropic_ttl", index=self._cache_anthropic_ttl_index())
2111
+
2112
+ def _choose_cache_anthropic_ttl(self, value: str) -> None:
2113
+ self.state.set_field(
2114
+ "anthropic_prompt_cache_ttl",
2115
+ _normalize_anthropic_prompt_cache_ttl(value),
2116
+ )
2117
+ self._goto(
2118
+ "cache_compaction_enabled",
2119
+ index=self._cache_compaction_enabled_index(),
2120
+ )
2121
+
2122
+ def _choose_cache_compaction_enabled(self, value: str) -> None:
2123
+ enabled = _normalize_bool_text(value, label="Cache-aware compaction")
2124
+ self.state.set_field("cache_aware_compaction", "true" if enabled else "false")
2125
+ self._goto("cache_compaction_min_trigger")
2126
+
2127
+ def _submit_cache_compaction_min_trigger(self, text: str) -> None:
2128
+ value = text.strip() or str(self.state.fields.get("cache_aware_min_trigger_ratio", ""))
2129
+ try:
2130
+ normalized = _normalize_cache_aware_min_trigger_ratio(value)
2131
+ except ValueError as exc:
2132
+ self._set_status(str(exc), "err")
2133
+ return
2134
+ self.state.set_field("cache_aware_min_trigger_ratio", normalized)
2135
+ self._goto("menu")
2136
+ self._set_status("Context/cache settings updated. Save to apply.", "ok")
2137
+
2138
+ def _cache_mode_index(self) -> int:
2139
+ current = _normalize_prompt_cache_mode(self.state.fields.get("prompt_cache_mode", "manual"))
2140
+ return PROMPT_CACHE_MODES.index(current) if current in PROMPT_CACHE_MODES else 0
2141
+
2142
+ def _cache_anthropic_enabled_index(self) -> int:
2143
+ current = _normalize_bool_text(
2144
+ self.state.fields.get("anthropic_prompt_cache_enabled", "false"),
2145
+ label="Anthropic prompt cache",
2146
+ )
2147
+ return 0 if current else 1
2148
+
2149
+ def _cache_anthropic_ttl_index(self) -> int:
2150
+ current = _normalize_anthropic_prompt_cache_ttl(
2151
+ self.state.fields.get("anthropic_prompt_cache_ttl", "5m")
2152
+ )
2153
+ return (
2154
+ ANTHROPIC_PROMPT_CACHE_TTLS.index(current)
2155
+ if current in ANTHROPIC_PROMPT_CACHE_TTLS
2156
+ else 0
2157
+ )
2158
+
2159
+ def _cache_compaction_enabled_index(self) -> int:
2160
+ current = _normalize_bool_text(
2161
+ self.state.fields.get("cache_aware_compaction", "true"),
2162
+ label="Cache-aware compaction",
2163
+ )
2164
+ return 0 if current else 1
2165
+
2166
+ @staticmethod
2167
+ def _optional_text_value(text: str, current: str) -> str:
2168
+ value = str(text or "").strip()
2169
+ if not value:
2170
+ return str(current or "").strip()
2171
+ if value.casefold() == "clear":
2172
+ return ""
2173
+ return value
2174
+
2175
+ def _finish_preset_chain_if_active(self, *, model: str) -> bool:
2176
+ """End the add-provider chain after the model is chosen.
2177
+
2178
+ Selecting a model normally advances to base URL / thinking / timeout. When
2179
+ we arrived here via "Add provider preset" we skip fields already carried by
2180
+ the profile. NVIDIA remains on the chain for one more step because reasoning
2181
+ controls differ between models hosted behind the same endpoint.
2182
+ """
2183
+ if not self._preset_setup_chain:
2184
+ return False
2185
+ preset = _active_preset(self.state)
2186
+ if preset is not None and preset.key == "nvidia":
2187
+ labels = _thinking_labels_for_state(self.state, model=model)
2188
+ if self.state.thinking_label not in labels:
2189
+ self.state.set_thinking_label("auto")
2190
+ self._goto("model_thinking", index=self._thinking_index())
2191
+ return True
2192
+ self._complete_preset_setup_chain(model=model)
2193
+ return True
2194
+
2195
+ def _complete_preset_setup_chain(self, *, model: str) -> None:
2196
+ """Finish an active add-provider chain and return to its provider screen."""
2197
+
2198
+ self._preset_setup_chain = False
2199
+ self._goto("provider")
2200
+ diag = self._provider_diag_first()
2201
+ if diag:
2202
+ self._set_status(f"Provider ready · model {model}. Provider diagnostic: {diag}", "warn")
2203
+ else:
2204
+ self._set_status(f"Provider ready · default model {model}. Save to apply.", "ok")
2205
+
2206
+ # ----------------------------------------------------- subagent overrides
2207
+
2208
+ def _submit_subagent_field(self, text: str) -> None:
2209
+ role, kind = self._sub_steps[self._sub_i]
2210
+ value = text.strip()
2211
+ if kind == "model":
2212
+ if value.casefold() == "clear":
2213
+ self.state.set_role_model(role, "")
2214
+ elif value:
2215
+ self.state.set_role_model(role, value)
2216
+ else:
2217
+ if value.casefold() == "clear":
2218
+ self.state.set_role_temperature(role, "")
2219
+ elif value:
2220
+ number = _finite_float(value, fallback=None)
2221
+ if number is None or number < 0:
2222
+ self._set_status("Temperature must be a non-negative number.", "err")
2223
+ return
2224
+ self.state.set_role_temperature(role, _format_number(number))
2225
+ self._advance_subagent()
2226
+
2227
+ def _advance_subagent(self) -> None:
2228
+ role, _kind = self._sub_steps[self._sub_i]
2229
+ self._sub_i += 1
2230
+ if self._sub_i >= len(self._sub_steps):
2231
+ self._goto("subagent_roles")
2232
+ self._set_status(f"{_role_label(role)} override updated. Save to apply.", "ok")
2233
+ else:
2234
+ self.status = ""
2235
+ self.status_tone = "dim"
2236
+
2237
+ # -------------------------------------------------------- forge overrides
2238
+
2239
+ def _submit_forge_field(self, text: str) -> None:
2240
+ role = self._forge_role or FORGE_ROLE_ORDER[0]
2241
+ value = text.strip()
2242
+ if value.casefold() == "clear":
2243
+ self.state.set_forge_role_model(role, "")
2244
+ elif value:
2245
+ self.state.set_forge_role_model(role, value)
2246
+ self._goto("forge_roles")
2247
+ self._set_status(f"{_role_label(role)} override updated. Save to apply.", "ok")
2248
+
2249
+ # ----------------------------------------------------------- api key
2250
+
2251
+ def _submit_api_key(self, text: str) -> None:
2252
+ value = text.strip()
2253
+ if not value:
2254
+ self._after_api_key_step(skipped=True)
2255
+ return
2256
+ if value.casefold() == "clear":
2257
+ self._goto("api_key_clear_confirm")
2258
+ return
2259
+ self.state.set_field("new_api_key", value)
2260
+ self._after_api_key_step(skipped=False)
2261
+
2262
+ def _after_api_key_step(self, *, skipped: bool) -> None:
2263
+ if self._preset_setup_chain:
2264
+ self.open_default_model()
2265
+ if skipped:
2266
+ self._set_status(
2267
+ "No key set yet. Pick the default model (add the key later).", "warn"
2268
+ )
2269
+ else:
2270
+ self._set_status("API key set. Now pick the default model.", "ok")
2271
+ return
2272
+ self._goto("menu")
2273
+ if not skipped:
2274
+ self._set_status("API key set. Save to apply.", "ok")
2275
+
2276
+ # -------------------------------------------------------- provider profile
2277
+
2278
+ def _choose_provider_action(self, value: str) -> None:
2279
+ if value == "back":
2280
+ self._goto("menu")
2281
+ elif value == "switch":
2282
+ if not self.state.profiles:
2283
+ self._set_status("No profiles configured yet.", "warn")
2284
+ return
2285
+ self._goto("provider_switch", index=self._provider_switch_index())
2286
+ elif value == "add_preset":
2287
+ self._goto("provider_add_preset")
2288
+ elif value == "add_custom":
2289
+ self._goto("provider_custom_name")
2290
+ elif value == "edit":
2291
+ self._enter_provider_edit()
2292
+ elif value == "remove":
2293
+ if not self.state.profiles:
2294
+ self._set_status("No profiles to remove.", "warn")
2295
+ return
2296
+ self._goto("provider_remove")
2297
+
2298
+ def _provider_switch_index(self) -> int:
2299
+ names = sorted(self.state.profiles)
2300
+ active = self.state.active_profile
2301
+ return names.index(active) if active in names else 0
2302
+
2303
+ def _choose_provider_switch(self, value: str) -> None:
2304
+ self.state.set_active_profile_name(value)
2305
+ self._goto("provider")
2306
+ diag = self._provider_diag_first()
2307
+ notices = [f"Active profile: {value}."]
2308
+ staged_profile = self.state.staged_api_key_target_profile()
2309
+ if staged_profile and staged_profile != value:
2310
+ notices.append(f"The unsaved API key remains bound to {staged_profile}.")
2311
+ clear_profile = self.state.clear_stored_key_profile
2312
+ if self.state.clear_stored_key_confirmed and clear_profile and clear_profile != value:
2313
+ notices.append(f"Stored-key removal remains bound to {clear_profile}.")
2314
+ if diag:
2315
+ notices.append(f"Provider diagnostic: {diag}")
2316
+ self._set_status(" ".join(notices), "warn" if len(notices) > 1 else "ok")
2317
+
2318
+ def _choose_preset(self, value: str) -> None:
2319
+ if value == _ADVANCED_PROVIDER_PRESETS_VALUE:
2320
+ self._goto("provider_add_preset_advanced")
2321
+ return
2322
+ self._pending_preset = next((p for p in PROFILE_PRESETS if p.key == value), None)
2323
+ if self._pending_preset is None:
2324
+ self._set_status("Unknown preset.", "err")
2325
+ return
2326
+ self._goto("provider_preset_name")
2327
+
2328
+ def _submit_provider_preset_name(self, text: str) -> None:
2329
+ name = text.strip() or getattr(self._pending_preset, "key", "custom")
2330
+ profile = make_profile_from_preset(self._pending_preset, name=name)
2331
+ if not profile.base_url:
2332
+ self._pending_preset_profile = profile
2333
+ self._goto("provider_preset_url")
2334
+ return
2335
+ self._finalize_preset_profile(profile)
2336
+
2337
+ def _submit_provider_preset_url(self, text: str) -> None:
2338
+ url = text.strip()
2339
+ if not url:
2340
+ self._set_status("Base URL is required.", "err")
2341
+ return
2342
+ base = self._pending_preset_profile
2343
+ profile = ProfileSpec(
2344
+ name=base.name,
2345
+ protocol=base.protocol,
2346
+ base_url=url,
2347
+ api_key_env=base.api_key_env,
2348
+ extra_headers=base.extra_headers,
2349
+ default_model=base.default_model,
2350
+ reasoning_effort=base.reasoning_effort,
2351
+ reasoning_trace_adapter=base.reasoning_trace_adapter,
2352
+ web_search_adapter=base.web_search_adapter,
2353
+ web_search_model=base.web_search_model,
2354
+ notes=base.notes,
2355
+ )
2356
+ self._finalize_preset_profile(profile)
2357
+
2358
+ def _finalize_preset_profile(self, profile: ProfileSpec) -> None:
2359
+ try:
2360
+ self.state.add_profile_spec(profile)
2361
+ except ConfigError as exc:
2362
+ self._goto("provider_preset_name")
2363
+ self._set_status(str(exc), "err")
2364
+ return
2365
+ warning = (getattr(self._pending_preset, "setup_warning", "") or "").strip()
2366
+ # Guided chain: provider preset → API key → default model, so adding a
2367
+ # provider lands fully configured instead of dropping back to the menu.
2368
+ self._preset_setup_chain = True
2369
+ self._goto("api_key")
2370
+ if warning:
2371
+ self._set_status(
2372
+ f"Profile {profile.name} added. Next: enter the API key (Esc to skip). {warning}",
2373
+ "warn",
2374
+ )
2375
+ else:
2376
+ self._set_status(
2377
+ f"Profile {profile.name} added. Next: enter the API key (Esc to skip).",
2378
+ "ok",
2379
+ )
2380
+
2381
+ def _submit_provider_custom_name(self, text: str) -> None:
2382
+ self._custom_name = (text.strip() or "custom").lower()
2383
+ self._goto("provider_custom_url")
2384
+
2385
+ def _submit_provider_custom_url(self, text: str) -> None:
2386
+ url = text.strip()
2387
+ if not url:
2388
+ self._set_status("Base URL is required.", "err")
2389
+ return
2390
+ self._custom_url = url
2391
+ self._goto("provider_custom_headers")
2392
+
2393
+ def _submit_provider_custom_headers(self, text: str) -> None:
2394
+ try:
2395
+ headers = _parse_header_text(text)
2396
+ except ConfigError as exc:
2397
+ self._set_status(str(exc), "err")
2398
+ return
2399
+ profile = ProfileSpec(
2400
+ name=self._custom_name,
2401
+ protocol="openai_compat",
2402
+ base_url=self._custom_url,
2403
+ extra_headers=headers,
2404
+ web_search_adapter="auto",
2405
+ web_search_model="",
2406
+ notes="Custom OpenAI-compatible endpoint.",
2407
+ )
2408
+ try:
2409
+ self.state.add_profile_spec(profile)
2410
+ except ConfigError as exc:
2411
+ self._goto("provider_custom_name")
2412
+ self._set_status(str(exc), "err")
2413
+ return
2414
+ self._goto("provider")
2415
+ diag = self._provider_diag_first()
2416
+ if diag:
2417
+ self._set_status(f"Profile {profile.name} added. Provider diagnostic: {diag}", "warn")
2418
+ else:
2419
+ self._set_status(f"Profile {profile.name} added.", "ok")
2420
+
2421
+ def _enter_provider_edit(self) -> None:
2422
+ if not self.state.active_profile or self.state.active_profile not in self.state.profiles:
2423
+ self._set_status("No active profile. Switch to one or add a new profile first.", "warn")
2424
+ return
2425
+ profile = ProfileSpec.from_dict(
2426
+ self.state.active_profile, self.state.profiles[self.state.active_profile]
2427
+ )
2428
+ if profile.auth_provider:
2429
+ self._set_status(
2430
+ "This subscription connection is provider-managed. Use Default model for "
2431
+ "model and reasoning choices.",
2432
+ "warn",
2433
+ )
2434
+ return
2435
+ self._edit_profile = profile
2436
+ self._edit_values = {
2437
+ "base_url": profile.base_url,
2438
+ "api_key_env": profile.api_key_env or "",
2439
+ "default_model": profile.default_model,
2440
+ "reasoning_trace_adapter": profile.reasoning_trace_adapter,
2441
+ "web_search_adapter": profile.web_search_adapter,
2442
+ "web_search_model": profile.web_search_model,
2443
+ "extra_headers": ", ".join(f"{k}={v}" for k, v in profile.extra_headers.items()),
2444
+ "notes": profile.notes,
2445
+ }
2446
+ self._edit_i = 0
2447
+ self._edit_secret_candidate = None
2448
+ self._goto("provider_edit")
2449
+
2450
+ def _submit_provider_edit(self, text: str) -> None:
2451
+ field = _EDIT_FIELDS[self._edit_i]
2452
+ value = text.strip()
2453
+ if not value:
2454
+ value = self._edit_values.get(field, "")
2455
+ # Accidental-secret guard (mirrors config_menu._resolve_non_secret_value).
2456
+ if value and value != _SECRET_FORCE_TOKEN and _looks_like_secret(value):
2457
+ self._edit_secret_candidate = value
2458
+ self._set_status(
2459
+ "That looks like an API key — paste keys in the API Key section. "
2460
+ "Re-enter, or type 'force' to store it anyway.",
2461
+ "warn",
2462
+ )
2463
+ return
2464
+ if value == _SECRET_FORCE_TOKEN and self._edit_secret_candidate is not None:
2465
+ value = self._edit_secret_candidate
2466
+ self._edit_secret_candidate = None
2467
+ if field == "web_search_adapter":
2468
+ try:
2469
+ value = normalize_web_search_adapter(value or "auto")
2470
+ except (ConfigError, ValueError) as exc:
2471
+ self._set_status(str(exc), "err")
2472
+ return
2473
+ if field == "reasoning_trace_adapter":
2474
+ try:
2475
+ profile = ProfileSpec.from_dict(
2476
+ self.state.active_profile,
2477
+ self.state.profiles[self.state.active_profile],
2478
+ )
2479
+ value = validate_reasoning_trace_adapter_for_protocol(
2480
+ protocol=profile.protocol,
2481
+ adapter=value or "auto",
2482
+ )
2483
+ except (ConfigError, ValueError) as exc:
2484
+ self._set_status(str(exc), "err")
2485
+ return
2486
+ if field == "extra_headers":
2487
+ try:
2488
+ _parse_header_text(value)
2489
+ except ConfigError as exc:
2490
+ self._set_status(str(exc), "err")
2491
+ return
2492
+ self._edit_values[field] = value
2493
+ self._edit_i += 1
2494
+ if self._edit_i >= len(_EDIT_FIELDS):
2495
+ self._finalize_provider_edit()
2496
+ else:
2497
+ self.status = ""
2498
+ self.status_tone = "dim"
2499
+
2500
+ def _finalize_provider_edit(self) -> None:
2501
+ values = self._edit_values
2502
+ self.state.update_active_profile_spec(
2503
+ base_url=values["base_url"],
2504
+ api_key_env=values["api_key_env"] or None,
2505
+ default_model=values["default_model"],
2506
+ reasoning_trace_adapter=values["reasoning_trace_adapter"] or "auto",
2507
+ web_search_adapter=values["web_search_adapter"] or "auto",
2508
+ web_search_model=values["web_search_model"],
2509
+ extra_headers=_parse_header_text(values["extra_headers"]),
2510
+ notes=values["notes"],
2511
+ )
2512
+ self._goto("provider")
2513
+ diag = self._provider_diag_first()
2514
+ if diag:
2515
+ self._set_status(f"Profile updated. Provider diagnostic: {diag}", "warn")
2516
+ else:
2517
+ self._set_status("Profile updated. Save to apply.", "ok")
2518
+
2519
+ def _choose_remove(self, value: str) -> None:
2520
+ self._pending_remove = value
2521
+ self._goto("provider_remove_confirm")
2522
+
2523
+ # ----------------------------------------------------------- busy / save
2524
+
2525
+ def run_busy(self) -> None:
2526
+ # Synchronous driver used by tests. The overlay splits these two calls
2527
+ # across its worker and UI threads.
2528
+ self.perform_busy()
2529
+ self.apply_busy_outcome()
2530
+
2531
+ def perform_busy(self) -> None:
2532
+ if self.stage in {"subscription_connecting", "subscription_disconnecting"}:
2533
+ self._perform_subscription_account_action()
2534
+ return
2535
+ self.perform_save()
2536
+
2537
+ def apply_busy_outcome(self) -> None:
2538
+ if self.stage in {"subscription_connecting", "subscription_disconnecting"}:
2539
+ self._apply_subscription_account_outcome()
2540
+ return
2541
+ self.apply_save_outcome()
2542
+
2543
+ def _perform_subscription_account_action(self) -> None:
2544
+ provider_id = self._subscription_provider_id or self.state.execution_runtime
2545
+ if provider_id == _ALYSIS_CONNECTION_ID:
2546
+ self._perform_alysis_account_action()
2547
+ return
2548
+ try:
2549
+ adapter = create_provider_auth(provider_id)
2550
+ if self.stage == "subscription_connecting":
2551
+ if self._subscription_switch_account:
2552
+ adapter.logout()
2553
+ status = adapter.login(method="browser", output_write=lambda _message: None)
2554
+ if not status.connected:
2555
+ raise ProviderAuthError(status.detail or "Provider sign-in did not complete.")
2556
+ self._subscription_action_outcome = ("connected", status, "")
2557
+ else:
2558
+ status = adapter.logout()
2559
+ if status.connected:
2560
+ raise ProviderAuthError(status.detail or "Provider credentials remain active.")
2561
+ self._subscription_action_outcome = ("disconnected", status, "")
2562
+ except (ProviderAuthError, ValueError) as exc:
2563
+ self._subscription_action_outcome = ("error", None, str(exc))
2564
+
2565
+ def _perform_alysis_account_action(self) -> None:
2566
+ """Connect/disconnect the Alysis Code account (device-flow login).
2567
+
2568
+ Runs on the busy worker like the runtime adapters. `login()` opens the
2569
+ browser, waits for approval, and itself activates + persists the
2570
+ `alysis` profile; the outcome applier then syncs the TUI state so a
2571
+ later Save doesn't revert what login already wrote.
2572
+ """
2573
+ from ... import account_login
2574
+
2575
+ try:
2576
+ if self.stage == "subscription_connecting":
2577
+ if self._subscription_switch_account:
2578
+ account_login.logout(self.cfg)
2579
+ result = account_login.login(
2580
+ self.cfg, timeout_s=300, output_write=lambda _message: None
2581
+ )
2582
+ self._alysis_login_result = result
2583
+ status = ProviderAccountStatus(
2584
+ connected=True,
2585
+ verified=True,
2586
+ account_label=result.email or "Alysis Code account",
2587
+ detail=f"Hosted model ready: {result.model}",
2588
+ )
2589
+ self._subscription_action_outcome = ("connected", status, "")
2590
+ else:
2591
+ account_login.logout(self.cfg)
2592
+ status = ProviderAccountStatus(
2593
+ connected=False,
2594
+ verified=False,
2595
+ detail="Key revoked and removed from this machine.",
2596
+ )
2597
+ self._subscription_action_outcome = ("disconnected", status, "")
2598
+ except (account_login.AlysisLoginError, ConfigError) as exc:
2599
+ self._subscription_action_outcome = ("error", None, str(exc))
2600
+
2601
+ def _apply_subscription_account_outcome(self) -> None:
2602
+ outcome = self._subscription_action_outcome
2603
+ self._subscription_action_outcome = None
2604
+ self._subscription_switch_account = False
2605
+ provider_id = self._subscription_provider_id or self.state.execution_runtime
2606
+ if provider_id == _ALYSIS_CONNECTION_ID:
2607
+ self._apply_alysis_account_outcome(outcome)
2608
+ return
2609
+ if outcome is None:
2610
+ self._goto("subscription_account")
2611
+ self._set_status("Subscription account action did not complete.", "err")
2612
+ return
2613
+ kind, status, message = outcome
2614
+ if kind == "error" or status is None:
2615
+ self._subscription_account_status = None
2616
+ self._goto("subscription_account")
2617
+ self._set_status(message or "Subscription account action failed.", "err")
2618
+ return
2619
+ self._subscription_account_status = status
2620
+ self.state._subscription_models_cache = ()
2621
+ self.state._subscription_models_loaded = False
2622
+ self._goto("subscription_account")
2623
+ if kind == "connected":
2624
+ self._set_status(
2625
+ "Subscription connected. Save model access, then review Default Model.",
2626
+ "ok",
2627
+ )
2628
+ else:
2629
+ detail = str(status.detail or "Disconnected locally.").strip()
2630
+ self._set_status(f"{detail} The subscription profile remains selected.", "warn")
2631
+
2632
+ def _apply_alysis_account_outcome(
2633
+ self, outcome: tuple[str, ProviderAccountStatus | None, str] | None
2634
+ ) -> None:
2635
+ if outcome is None:
2636
+ self._goto("subscription_account")
2637
+ self._set_status("Alysis Code account action did not complete.", "err")
2638
+ return
2639
+ kind, status, message = outcome
2640
+ if kind == "error" or status is None:
2641
+ self._subscription_account_status = None
2642
+ self._goto("subscription_account")
2643
+ self._set_status(message or "Alysis Code account action failed.", "err")
2644
+ return
2645
+ self._subscription_account_status = status
2646
+ # Either action changed the on-disk credentials, so the live chat
2647
+ # session must reload when config closes (whether via Save or Esc) —
2648
+ # otherwise it keeps using the old (now revoked) key in memory.
2649
+ self.saved = True
2650
+ self.changes_count = max(int(getattr(self, "changes_count", 0) or 0), 1)
2651
+ if kind == "connected":
2652
+ # login() already activated + saved the alysis profile in cfg;
2653
+ # mirror it into the TUI state so a later Save is consistent.
2654
+ result = getattr(self, "_alysis_login_result", None)
2655
+ self._alysis_login_result = None
2656
+ from ...profiles import get_profile
2657
+
2658
+ profile = get_profile(self.cfg, _ALYSIS_CONNECTION_ID)
2659
+ if profile is not None:
2660
+ self.state.profiles[_ALYSIS_CONNECTION_ID] = profile.to_dict()
2661
+ self.state.active_profile = _ALYSIS_CONNECTION_ID
2662
+ self.state.set_execution_backend("native")
2663
+ model = str(getattr(result, "model", "") or "").strip()
2664
+ if model:
2665
+ self.state.fields["model"] = model
2666
+ self._goto("subscription_account")
2667
+ self._set_status(
2668
+ "Alysis Code connected — hosted DeepSeek active. Esc to exit config.",
2669
+ "ok",
2670
+ )
2671
+ else:
2672
+ self._goto("subscription_account")
2673
+ self._set_status(
2674
+ "Logged out. Hosted requests will be refused until you sign in again.",
2675
+ "warn",
2676
+ )
2677
+
2678
+ def _run_saving(self) -> None:
2679
+ self.perform_save()
2680
+ self.apply_save_outcome()
2681
+
2682
+ def _run_switching(self) -> None:
2683
+ # "Switch now" persists everything (incl. the new default workspace) exactly
2684
+ # like a save; apply_save_outcome then promotes the pending switch target.
2685
+ self.perform_save()
2686
+ self.apply_save_outcome()
2687
+
2688
+ def perform_save(self) -> None:
2689
+ """Blocking save I/O. Records the outcome; writes NO renderer-visible state.
2690
+
2691
+ Safe to run on a worker thread: it only touches disk/keyring and stashes the
2692
+ result on :attr:`_save_outcome` / :attr:`result` / :attr:`changes_count`
2693
+ (none of which the render callbacks read). The stage/status transition is
2694
+ applied separately by :meth:`apply_save_outcome` on the UI thread.
2695
+ """
2696
+ result = self.state.commit_to(self.cfg)
2697
+ if not result.saved:
2698
+ self._save_outcome = ("error", result.error or "Config validation failed.")
2699
+ return
2700
+ try:
2701
+ save_config(self.cfg)
2702
+ new_key = self.state.new_api_key.strip()
2703
+ if new_key:
2704
+ key_profile = self.state.staged_api_key_target_profile()
2705
+ if key_profile:
2706
+ save_persisted_profile_key(key_profile, new_key)
2707
+ else:
2708
+ save_persisted_api_key(new_key)
2709
+ if self.state.clear_stored_key_confirmed:
2710
+ clear_profile = self.state.clear_stored_key_profile or self.state.active_profile
2711
+ if clear_profile:
2712
+ clear_persisted_profile_key(clear_profile)
2713
+ else:
2714
+ clear_persisted_api_key()
2715
+ except (ConfigError, OSError) as exc:
2716
+ self._save_outcome = ("error", f"Failed to save config: {exc}")
2717
+ return
2718
+ self.result = result
2719
+ self.changes_count = len(result.changes) + (1 if result.api_key_changed else 0)
2720
+ self._save_outcome = ("saved", "")
2721
+
2722
+ def set_save_failure(self, message: str) -> None:
2723
+ """Record an unexpected save failure (called by the overlay's worker)."""
2724
+ self._save_outcome = ("error", message or "Failed to save config.")
2725
+
2726
+ def set_busy_failure(self, message: str) -> None:
2727
+ if self.stage in {"subscription_connecting", "subscription_disconnecting"}:
2728
+ self._subscription_action_outcome = (
2729
+ "error",
2730
+ None,
2731
+ message or "Subscription account action failed.",
2732
+ )
2733
+ return
2734
+ self.set_save_failure(message)
2735
+
2736
+ def apply_save_outcome(self) -> None:
2737
+ """Apply the recorded save outcome as a stage transition (UI thread)."""
2738
+ outcome = self._save_outcome
2739
+ self._save_outcome = None
2740
+ if outcome is None:
2741
+ return
2742
+ kind, message = outcome
2743
+ if kind == "saved":
2744
+ self.saved = True
2745
+ if self._switch_target:
2746
+ self.switch_workspace = self._switch_target
2747
+ self._finish(True)
2748
+ else:
2749
+ self._switch_target = None
2750
+ self._goto("menu")
2751
+ self._set_status(message, "err")
2752
+
2753
+
2754
+ __all__ = ["ConfigFlow"]