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,1622 @@
1
+ """Presentation-agnostic state machine for the full-screen TUI setup wizard.
2
+
3
+ The classic first-run wizard (:mod:`cli_impl.setup_wizard`) is a Rich-print +
4
+ prompt_toolkit-prompt flow. When ``ALYSIS_TUI`` is on we want the *onboarding*
5
+ to match the alt-screen launch experience too, so this module expresses the flow
6
+ as a step machine that renders a :class:`Screen` description. Unlike the classic
7
+ wizard's method-first branching, the TUI opens with a single merged "Connect a
8
+ Provider" list (subscription sign-ins and API-key providers side by side); the
9
+ selected row dispatches to the right branch — API-key/model setup or subscription OAuth — followed by workspace and
10
+ commit. The prompt_toolkit application in
11
+ :mod:`cli_impl.tui.setup_app` reads that description and routes key events back
12
+ into the flow; nothing here imports prompt_toolkit, so the whole thing is unit
13
+ testable by driving the public methods synchronously.
14
+
15
+ The wizard *logic* is reused wholesale from :mod:`cli_impl.setup_wizard` (preset
16
+ list, key/model validation, persistence) and the low-level sandbox/login modules,
17
+ so there is a single source of truth for "what setup does"; this module only owns
18
+ "how the TUI walks through it".
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import io
24
+ import os
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass, field, replace
27
+ from pathlib import Path
28
+ from typing import Any, Literal
29
+
30
+ from rich.console import Console as _RichConsole
31
+
32
+ from ...config import AppConfig, ConfigError
33
+ from ...profile_presets import (
34
+ canonical_model_alias_for_preset,
35
+ make_profile_from_preset,
36
+ )
37
+ from ...profiles import ProfileSpec
38
+ from ...workspace_binding import WorkspaceBindingError, resolve_workspace_binding
39
+ from .. import setup_wizard as _wiz
40
+
41
+ # Reuse the wizard's result dataclasses so the persisted shapes stay identical.
42
+ _ExecutionStepResult = _wiz._ExecutionStepResult
43
+ _DelegatedAuthResult = _wiz._DelegatedAuthResult
44
+ _ProfileStepResult = _wiz._ProfileStepResult
45
+ _ApiKeyStepResult = _wiz._ApiKeyStepResult
46
+ _ModelStepResult = _wiz._ModelStepResult
47
+ _WorkspaceStepResult = _wiz._WorkspaceStepResult
48
+ _SandboxStepResult = _wiz._SandboxStepResult
49
+
50
+ Mode = Literal["list", "input", "busy", "confirm", "message", "done"]
51
+ Tone = Literal["ok", "warn", "err", "dim", "plain"]
52
+
53
+ # Stages that count toward the "Step N of M" progress label (the visible
54
+ # decisions the user makes).
55
+ _PROGRESS_STAGES = (
56
+ "welcome",
57
+ "connect_provider",
58
+ "api_key",
59
+ "model",
60
+ "workspace",
61
+ "sandbox_choice",
62
+ )
63
+ _SUBSCRIPTION_PROGRESS_STAGES = ("welcome", "connect_provider", "workspace")
64
+ _PROGRESS_LABELS = {
65
+ "welcome": "Welcome",
66
+ "connect_provider": "Provider",
67
+ "api_key": "API key",
68
+ "model": "Model",
69
+ "workspace": "Workspace",
70
+ "sandbox_choice": "Sandbox",
71
+ }
72
+ _PROGRESS_ALIASES = {
73
+ "provider_advanced": ("connect_provider", "Provider (advanced)"),
74
+ "custom_name": ("connect_provider", "Provider (custom)"),
75
+ "custom_url": ("connect_provider", "Provider (custom)"),
76
+ "custom_headers": ("connect_provider", "Provider (custom)"),
77
+ "validating_key": ("api_key", "API key"),
78
+ "custom_model": ("model", "Model"),
79
+ "validating_model": ("model", "Model"),
80
+ "model_thinking": ("model", "Reasoning"),
81
+ "model_not_found_confirm": ("model", "Model"),
82
+ "workspace_create_confirm": ("workspace", "Workspace"),
83
+ "checking_runtime_auth": ("workspace", "Subscription"),
84
+ "runtime_login_confirm": ("workspace", "Subscription"),
85
+ "runtime_logging_in": ("workspace", "Subscription"),
86
+ "diagnosing_sandbox": ("sandbox_choice", "Sandbox"),
87
+ "installing_sandbox": ("sandbox_choice", "Sandbox"),
88
+ "pulling_sandbox": ("sandbox_choice", "Sandbox"),
89
+ "disabling_sandbox": ("sandbox_choice", "Sandbox"),
90
+ "sandbox_pull_confirm": ("sandbox_choice", "Sandbox"),
91
+ }
92
+ _MAX_KEY_ATTEMPTS = _wiz._MAX_API_KEY_VALIDATION_ATTEMPTS
93
+ _POST_COMMIT_STAGES = frozenset(
94
+ {
95
+ "checking_runtime_auth",
96
+ "runtime_login_confirm",
97
+ "runtime_logging_in",
98
+ "diagnosing_sandbox",
99
+ "sandbox_choice",
100
+ "sandbox_pull_confirm",
101
+ "installing_sandbox",
102
+ "pulling_sandbox",
103
+ "disabling_sandbox",
104
+ "login_confirm",
105
+ "logging_in",
106
+ }
107
+ )
108
+
109
+
110
+ # Cheap stage → interaction-mode lookup so the application's key-binding filters
111
+ # don't have to build a full :class:`Screen` (and its row list) on every repaint.
112
+ _STAGE_MODE: dict[str, Mode] = {
113
+ "welcome": "message",
114
+ "complete": "message",
115
+ "fatal": "message",
116
+ "connect_provider": "list",
117
+ "provider_advanced": "list",
118
+ "model": "list",
119
+ "model_thinking": "list",
120
+ "sandbox_choice": "list",
121
+ "custom_name": "input",
122
+ "custom_url": "input",
123
+ "custom_headers": "input",
124
+ "api_key": "input",
125
+ "custom_model": "input",
126
+ "workspace": "input",
127
+ "workspace_create_confirm": "confirm",
128
+ "validating_key": "busy",
129
+ "validating_model": "busy",
130
+ "committing": "busy",
131
+ "checking_runtime_auth": "busy",
132
+ "runtime_logging_in": "busy",
133
+ "diagnosing_sandbox": "busy",
134
+ "installing_sandbox": "busy",
135
+ "pulling_sandbox": "busy",
136
+ "disabling_sandbox": "busy",
137
+ "logging_in": "busy",
138
+ "sandbox_pull_confirm": "confirm",
139
+ "model_not_found_confirm": "confirm",
140
+ "login_confirm": "confirm",
141
+ "runtime_login_confirm": "confirm",
142
+ "cancel_confirm": "confirm",
143
+ "done": "done",
144
+ }
145
+
146
+
147
+ @dataclass
148
+ class Row:
149
+ """One selectable option in a ``list`` screen (mirrors the picker rows).
150
+
151
+ ``kind`` distinguishes real options ("item") from visual-only rows: a
152
+ "header" renders as a dim section caption and a "spacer" as a blank line;
153
+ neither is selectable. ``tone`` optionally tints the description ("warn"
154
+ highlights a problem summary, e.g. a missing required setting).
155
+ """
156
+
157
+ label: str
158
+ description: str = ""
159
+ value: str = ""
160
+ current: bool = False
161
+ kind: str = "item"
162
+ tone: str = ""
163
+
164
+
165
+ @dataclass
166
+ class Screen:
167
+ """A render description the application paints; pure data, no widgets."""
168
+
169
+ stage: str
170
+ mode: Mode
171
+ title: str = ""
172
+ subtitle: str = ""
173
+ # Free-form lines for ``message`` screens (welcome / complete / fatal).
174
+ lines: list[tuple[str, Tone]] = field(default_factory=list)
175
+ # ``list`` mode.
176
+ rows: list[Row] = field(default_factory=list)
177
+ index: int = 0
178
+ # Whether ``list`` rows carry "N." prefixes and answer 1-9 digit jumps.
179
+ numbered: bool = True
180
+ # ``input`` mode.
181
+ input_label: str = ""
182
+ input_default: str = ""
183
+ input_password: bool = False
184
+ # A status line shown under the body (validation result / busy detail).
185
+ status: str = ""
186
+ status_tone: Tone = "dim"
187
+ # ``busy`` mode: which executor the app should use to run :meth:`run_busy`.
188
+ busy_kind: Literal["thread", "terminal"] = "thread"
189
+ busy_label: str = ""
190
+ hint: str = ""
191
+ progress: str = ""
192
+ # ``confirm`` mode: which option Enter selects.
193
+ confirm_default: bool = False
194
+ # Set once the flow terminates so the app can exit with the right result.
195
+ success: bool | None = None
196
+
197
+
198
+ class SetupFlow:
199
+ """Drives the TUI setup wizard one screen at a time.
200
+
201
+ The application calls :meth:`screen` to render, the navigation/selection
202
+ methods on key events, and — whenever the current screen is ``busy`` —
203
+ :meth:`run_busy` on a worker (or in a restored terminal for ``terminal``
204
+ busy kinds). Every transition is synchronous and side-effect-explicit so
205
+ tests can walk the whole flow without a terminal.
206
+ """
207
+
208
+ def __init__(self, *, report: Callable[[str], None] | None = None) -> None:
209
+ self._report = report or (lambda _msg: None)
210
+ self.stage = "welcome"
211
+ self.index = 0
212
+ self.status = ""
213
+ self.status_tone: Tone = "dim"
214
+ # Collected results.
215
+ self.execution_result: _ExecutionStepResult | None = None
216
+ self.profile_result: _ProfileStepResult | None = None
217
+ self.api_key_result: _ApiKeyStepResult | None = None
218
+ self.model_result: _ModelStepResult | None = None
219
+ self.workspace_result: _WorkspaceStepResult | None = None
220
+ self.sandbox_result: _SandboxStepResult | None = None
221
+ self.cfg: AppConfig | None = None
222
+ self.login_summary: str = ""
223
+ self.login_ok: bool = False
224
+ self.runtime_auth_summary: str = ""
225
+ self.runtime_auth_connected: bool | None = None
226
+ self.diagnostic_lines: list[str] = []
227
+ self.fatal_error: str = ""
228
+ self.success: bool | None = None
229
+ # Transient working state.
230
+ self._key_attempts = 0
231
+ self._custom_name = ""
232
+ self._custom_url = ""
233
+ self._custom_headers: dict[str, str] = {}
234
+ self._last_model_not_found = ""
235
+ self._sandbox_diag: Any = None
236
+ self._sandbox_plan: Any = None
237
+ self._resume_stage = "" # stage to return to after a cancel prompt
238
+ self._pending_profile: ProfileSpec | None = None
239
+ self._pending_key = ""
240
+ self._pending_workspace_path: Path | None = None
241
+ self._discovered_model_options: tuple[Any, ...] = ()
242
+ self._model_catalog_warning = ""
243
+
244
+ # ---------------------------------------------------------------- helpers
245
+
246
+ def set_report(self, report: Callable[[str], None]) -> None:
247
+ self._report = report
248
+
249
+ def current_mode(self) -> Mode:
250
+ return _STAGE_MODE.get(self.stage, "message")
251
+
252
+ def busy_kind(self) -> str:
253
+ return (
254
+ "terminal"
255
+ if self.stage in {"installing_sandbox", "pulling_sandbox", "runtime_logging_in"}
256
+ else "thread"
257
+ )
258
+
259
+ def _hosted_mimo(self) -> bool:
260
+ preset = self.profile_result.preset if self.profile_result else None
261
+ if preset is None:
262
+ return False
263
+ try:
264
+ from ...alysis_cloud import PROFILE_KEY
265
+ except Exception:
266
+ return False
267
+ return preset.key == PROFILE_KEY
268
+
269
+ def _progress(self, stage: str) -> str:
270
+ uses_subscription = (
271
+ self.execution_result is not None and self.execution_result.backend == "delegated"
272
+ )
273
+ progress_stages = _SUBSCRIPTION_PROGRESS_STAGES if uses_subscription else _PROGRESS_STAGES
274
+ if stage in _PROGRESS_ALIASES:
275
+ base_stage, label = _PROGRESS_ALIASES[stage]
276
+ if base_stage in progress_stages:
277
+ number = progress_stages.index(base_stage) + 1
278
+ return f"Step {number} of {len(progress_stages)} · {label}"
279
+ if stage in progress_stages:
280
+ number = progress_stages.index(stage) + 1
281
+ label = _PROGRESS_LABELS.get(stage, stage.title())
282
+ return f"Step {number} of {len(progress_stages)} · {label}"
283
+ return f"Finishing · {stage.replace('_', ' ')}"
284
+
285
+ def _set_status(self, text: str, tone: Tone = "dim") -> None:
286
+ self.status = text
287
+ self.status_tone = tone
288
+
289
+ # ------------------------------------------------------------- rendering
290
+
291
+ def screen(self) -> Screen:
292
+ builder = getattr(self, f"_screen_{self.stage}", None)
293
+ if builder is None:
294
+ return Screen(
295
+ stage=self.stage,
296
+ mode="message",
297
+ title="Setup",
298
+ lines=[(f"(unknown stage {self.stage})", "err")],
299
+ )
300
+ scr: Screen = builder()
301
+ scr.progress = self._progress(self.stage)
302
+ if not scr.status:
303
+ scr.status = self.status
304
+ scr.status_tone = self.status_tone
305
+ scr.index = self.index
306
+ scr.success = self.success
307
+ return scr
308
+
309
+ def _screen_welcome(self) -> Screen:
310
+ return Screen(
311
+ stage="welcome",
312
+ mode="message",
313
+ title="Welcome to Alysis Code",
314
+ subtitle="A coding agent that runs in your terminal.",
315
+ lines=[
316
+ ("First pick the AI provider Alysis Code should connect to.", "plain"),
317
+ ("", "plain"),
318
+ (
319
+ "One list covers everything: subscription sign-ins, API-key "
320
+ "providers, and local endpoints.",
321
+ "dim",
322
+ ),
323
+ ("Setup then asks for the workspace folder you want to work on.", "dim"),
324
+ ],
325
+ hint="▶ Press Enter to begin",
326
+ )
327
+
328
+ def _screen_connect_provider(self) -> Screen:
329
+ # One merged list (Kilo-style): subscription sign-ins first, then
330
+ # API-key providers, with local/custom endpoints behind the advanced
331
+ # row. The description column carries the auth method so no upfront
332
+ # method choice is needed.
333
+ rows = [
334
+ Row(label=label, description=description, value=value)
335
+ for value, label, description in _wiz._connect_provider_picker_rows()
336
+ ]
337
+ return Screen(
338
+ stage="connect_provider",
339
+ mode="list",
340
+ title="Connect a Provider",
341
+ subtitle="Pick the provider you want Alysis Code to use.",
342
+ rows=rows,
343
+ hint="↑↓ move · Enter select · Esc back",
344
+ )
345
+
346
+ def _screen_provider_advanced(self) -> Screen:
347
+ rows = [
348
+ Row(label=_wiz.preset_selection_label(preset), value=preset.key)
349
+ for preset in _wiz._advanced_setup_presets()
350
+ ]
351
+ return Screen(
352
+ stage="provider_advanced",
353
+ mode="list",
354
+ title="Advanced Provider Profile",
355
+ subtitle=(
356
+ "Local endpoints (Ollama, LM Studio, vLLM), custom URLs, legacy "
357
+ "OpenAI-compatible presets, and Alysis Code account sign-in."
358
+ ),
359
+ rows=rows,
360
+ hint="↑↓ move · Enter select · Esc back",
361
+ )
362
+
363
+ def _screen_custom_name(self) -> Screen:
364
+ return Screen(
365
+ stage="custom_name",
366
+ mode="input",
367
+ title="Custom endpoint",
368
+ subtitle="Name this profile (lowercase, no spaces).",
369
+ input_label="Profile name",
370
+ input_default=self._custom_name or "custom",
371
+ hint="Enter next · Esc back",
372
+ )
373
+
374
+ def _screen_custom_url(self) -> Screen:
375
+ return Screen(
376
+ stage="custom_url",
377
+ mode="input",
378
+ title="Custom endpoint",
379
+ subtitle="The OpenAI-compatible base URL for this provider.",
380
+ input_label="Base URL",
381
+ input_default=self._custom_url,
382
+ hint="Enter next · Esc back",
383
+ )
384
+
385
+ def _screen_custom_headers(self) -> Screen:
386
+ existing = ", ".join(f"{k}={v}" for k, v in (self._custom_headers or {}).items())
387
+ return Screen(
388
+ stage="custom_headers",
389
+ mode="input",
390
+ title="Custom endpoint",
391
+ subtitle="Extra request headers, if your endpoint needs them (org id, x-api-key…).",
392
+ input_label="Extra headers (k=v, comma-separated)",
393
+ input_default=existing or "(none — Enter to skip)",
394
+ hint="Enter next · Esc back",
395
+ )
396
+
397
+ def _screen_api_key(self) -> Screen:
398
+ profile = self.profile_result.profile # type: ignore[union-attr]
399
+ required = bool(profile.api_key_env)
400
+ has_key = self.api_key_result is not None and bool(self.api_key_result.api_key)
401
+ if required:
402
+ label = "Paste your API key"
403
+ if has_key:
404
+ label += " (Enter to keep current)"
405
+ else:
406
+ label = "API key (optional — Enter to skip)"
407
+ sub = f"Alysis Code will use {self.profile_result.label}." # type: ignore[union-attr]
408
+ if profile.api_key_env:
409
+ sub += f" The key can also be set via {profile.api_key_env}."
410
+ return Screen(
411
+ stage="api_key",
412
+ mode="input",
413
+ title="API Key",
414
+ subtitle=sub,
415
+ input_label=label,
416
+ input_password=True,
417
+ hint="Enter validate · Esc back",
418
+ )
419
+
420
+ def _screen_model(self) -> Screen:
421
+ rows: list[Row] = []
422
+ for value, label, description in _wiz._model_picker_rows( # type: ignore[arg-type]
423
+ self.profile_result,
424
+ discovered_models=self._discovered_model_options,
425
+ ):
426
+ rows.append(Row(label=label, description=description, value=value))
427
+ return Screen(
428
+ stage="model",
429
+ mode="list",
430
+ title="Default Model",
431
+ subtitle=f"Pick the model Alysis Code will use by default for {self.profile_result.label}.", # type: ignore[union-attr]
432
+ rows=rows,
433
+ status=self._model_catalog_warning,
434
+ status_tone="warn" if self._model_catalog_warning else "dim",
435
+ hint="↑↓ move · 1-9 pick · Enter select · Esc back",
436
+ )
437
+
438
+ def _screen_model_thinking(self) -> Screen:
439
+ model = self.model_result.model if self.model_result is not None else ""
440
+ current = self.model_result.reasoning_label if self.model_result is not None else "auto"
441
+ labels = _wiz._setup_reasoning_labels( # type: ignore[arg-type]
442
+ self.profile_result,
443
+ model=model,
444
+ current=current,
445
+ )
446
+ descriptions = {
447
+ "off": "disable reasoning with this model's documented NVIDIA control",
448
+ "low": "lower reasoning effort",
449
+ "medium": "balanced reasoning effort",
450
+ "high": "full reasoning effort",
451
+ "max": "maximum reasoning effort",
452
+ "auto": "use the NVIDIA-hosted model's default",
453
+ }
454
+ return Screen(
455
+ stage="model_thinking",
456
+ mode="list",
457
+ title="Reasoning Effort",
458
+ subtitle=f"Verified reasoning modes for {model} on NVIDIA.",
459
+ rows=[
460
+ Row(
461
+ label=label,
462
+ description=descriptions.get(label, "provider-documented reasoning effort"),
463
+ value=label,
464
+ current=label == current,
465
+ )
466
+ for label in labels
467
+ ],
468
+ hint="↑↓ move · Enter select · Esc back",
469
+ )
470
+
471
+ def _screen_custom_model(self) -> Screen:
472
+ return Screen(
473
+ stage="custom_model",
474
+ mode="input",
475
+ title="Default Model",
476
+ subtitle="Type any model id supported by this provider.",
477
+ input_label="Model",
478
+ input_default=self._last_model_not_found,
479
+ hint="Enter next · Esc back",
480
+ )
481
+
482
+ def _screen_workspace(self) -> Screen:
483
+ default = _wiz._suggest_workspace_default()
484
+ prev = self.workspace_result.workspace if self.workspace_result else os.fspath(default)
485
+ return Screen(
486
+ stage="workspace",
487
+ mode="input",
488
+ title="Workspace",
489
+ subtitle="Alysis Code reads and edits files inside one folder.",
490
+ input_label="Workspace folder",
491
+ input_default=prev,
492
+ hint="Enter confirm · Esc back",
493
+ )
494
+
495
+ def _screen_workspace_create_confirm(self) -> Screen:
496
+ pending = os.fspath(self._pending_workspace_path or "")
497
+ return Screen(
498
+ stage="workspace_create_confirm",
499
+ mode="confirm",
500
+ title="Workspace",
501
+ subtitle="That folder does not exist yet.",
502
+ lines=[(f"Create it now? {pending}", "plain")],
503
+ hint="Y create · N choose another · Esc back",
504
+ confirm_default=True,
505
+ )
506
+
507
+ # ----- busy screens (the app runs run_busy(), they transition themselves)
508
+
509
+ def _screen_validating_key(self) -> Screen:
510
+ return Screen(
511
+ stage="validating_key", mode="busy", title="API Key", busy_label="Validating key…"
512
+ )
513
+
514
+ def _screen_validating_model(self) -> Screen:
515
+ return Screen(
516
+ stage="validating_model",
517
+ mode="busy",
518
+ title="Default Model",
519
+ busy_label="Validating model…",
520
+ )
521
+
522
+ def _screen_committing(self) -> Screen:
523
+ return Screen(stage="committing", mode="busy", title="Saving", busy_label="Saving setup…")
524
+
525
+ def _screen_checking_runtime_auth(self) -> Screen:
526
+ return Screen(
527
+ stage="checking_runtime_auth",
528
+ mode="busy",
529
+ title="AI subscription",
530
+ busy_label="Checking subscription connection…",
531
+ )
532
+
533
+ def _screen_runtime_logging_in(self) -> Screen:
534
+ return Screen(
535
+ stage="runtime_logging_in",
536
+ mode="busy",
537
+ busy_kind="terminal",
538
+ title="AI subscription",
539
+ busy_label="Opening provider sign-in…",
540
+ )
541
+
542
+ def _screen_diagnosing_sandbox(self) -> Screen:
543
+ return Screen(
544
+ stage="diagnosing_sandbox",
545
+ mode="busy",
546
+ title="Sandbox",
547
+ busy_label="Checking sandbox readiness…",
548
+ )
549
+
550
+ def _screen_installing_sandbox(self) -> Screen:
551
+ return Screen(
552
+ stage="installing_sandbox",
553
+ mode="busy",
554
+ busy_kind="terminal",
555
+ title="Sandbox",
556
+ busy_label="Installing bubblewrap… (watch the terminal — you may be asked for sudo)",
557
+ )
558
+
559
+ def _screen_pulling_sandbox(self) -> Screen:
560
+ return Screen(
561
+ stage="pulling_sandbox",
562
+ mode="busy",
563
+ busy_kind="terminal",
564
+ title="Sandbox",
565
+ busy_label="Pulling sandbox image… (this can take a minute)",
566
+ )
567
+
568
+ def _screen_disabling_sandbox(self) -> Screen:
569
+ return Screen(
570
+ stage="disabling_sandbox", mode="busy", title="Sandbox", busy_label="Disabling sandbox…"
571
+ )
572
+
573
+ def _screen_logging_in(self) -> Screen:
574
+ return Screen(
575
+ stage="logging_in", mode="busy", title="Alysis Code account", busy_label="Connecting…"
576
+ )
577
+
578
+ # ----- confirm screens
579
+
580
+ def _screen_sandbox_choice(self) -> Screen:
581
+ rows: list[Row] = []
582
+ if self._sandbox_plan is not None:
583
+ rows.append(
584
+ Row(
585
+ label="Install bubblewrap now (keeps sandbox on)",
586
+ description=f"Runs: {self._sandbox_plan.display}",
587
+ value="install_bwrap",
588
+ )
589
+ )
590
+ rows.append(
591
+ Row(
592
+ label="Run without sandbox for now (less safe)",
593
+ description="Commands run on the host shell. Re-enable anytime in /config.",
594
+ value="disable",
595
+ )
596
+ )
597
+ rows.append(
598
+ Row(
599
+ label="Decide later",
600
+ description="Finish with `alysis sandbox setup`.",
601
+ value="later",
602
+ )
603
+ )
604
+ return Screen(
605
+ stage="sandbox_choice",
606
+ mode="list",
607
+ title="Sandbox",
608
+ subtitle="No sandbox backend (bubblewrap or Docker) was found. How do you want to proceed?",
609
+ rows=rows,
610
+ hint="↑↓ move · Enter select · Esc skip",
611
+ )
612
+
613
+ def _screen_sandbox_pull_confirm(self) -> Screen:
614
+ return Screen(
615
+ stage="sandbox_pull_confirm",
616
+ mode="confirm",
617
+ title="Sandbox",
618
+ subtitle="The sandbox image was not found locally.",
619
+ lines=[("Pull the Alysis Code sandbox image now? (~50MB)", "plain")],
620
+ hint="Y pull · N skip · Esc skip",
621
+ confirm_default=True,
622
+ )
623
+
624
+ def _screen_model_not_found_confirm(self) -> Screen:
625
+ return Screen(
626
+ stage="model_not_found_confirm",
627
+ mode="confirm",
628
+ title="Default Model",
629
+ subtitle="The provider reported this model is missing.",
630
+ lines=[(f"Use custom model '{self._last_model_not_found}' anyway?", "warn")],
631
+ hint="Y use it · N pick again · Esc back",
632
+ )
633
+
634
+ def _screen_login_confirm(self) -> Screen:
635
+ return Screen(
636
+ stage="login_confirm",
637
+ mode="confirm",
638
+ title="Alysis Code account",
639
+ subtitle="Your provider is the Alysis Code-hosted MiMo endpoint.",
640
+ lines=[("Connect your Alysis Code account now?", "plain")],
641
+ hint="Y connect · N later · Esc skip",
642
+ confirm_default=True,
643
+ )
644
+
645
+ def _screen_runtime_login_confirm(self) -> Screen:
646
+ runtime_label = (
647
+ self.execution_result.label if self.execution_result is not None else "AI subscription"
648
+ )
649
+ return Screen(
650
+ stage="runtime_login_confirm",
651
+ mode="confirm",
652
+ title="Connect subscription",
653
+ subtitle=f"{runtime_label} is not connected.",
654
+ lines=[
655
+ ("Connect now using the provider's browser login?", "plain"),
656
+ (
657
+ "Provider login is an immediate external side effect. Cancelling or "
658
+ "leaving setup later does not undo it.",
659
+ "warn",
660
+ ),
661
+ ],
662
+ hint="Y connect · N later · Esc skip",
663
+ confirm_default=True,
664
+ )
665
+
666
+ def _screen_cancel_confirm(self) -> Screen:
667
+ settings_saved = self._resume_stage in _POST_COMMIT_STAGES
668
+ return Screen(
669
+ stage="cancel_confirm",
670
+ mode="confirm",
671
+ title="Cancel setup",
672
+ lines=(
673
+ [
674
+ ("Leave setup? Your model access settings are already saved.", "warn"),
675
+ ("Leaving does not undo provider sign-in or sign-out actions.", "dim"),
676
+ ]
677
+ if settings_saved
678
+ else [("Cancel setup? No changes will be saved.", "warn")]
679
+ ),
680
+ hint="Y cancel · N keep going",
681
+ )
682
+
683
+ def _screen_complete(self) -> Screen:
684
+ lines = self._summary_lines()
685
+ delegated = self.cfg is not None and self.cfg.execution.backend == "delegated"
686
+ subscription_selection_required = False
687
+ if self.cfg is not None and not delegated:
688
+ try:
689
+ from ...profiles import active_subscription_selection_ready, get_active_profile
690
+
691
+ subscription_selection_required = bool(
692
+ get_active_profile(self.cfg).auth_provider
693
+ and not active_subscription_selection_ready(self.cfg)
694
+ )
695
+ except Exception:
696
+ subscription_selection_required = False
697
+ return Screen(
698
+ stage="complete",
699
+ mode="message",
700
+ title="Setup complete",
701
+ lines=lines,
702
+ hint=(
703
+ "▶ Press Enter to finish setup"
704
+ if delegated
705
+ else (
706
+ "▶ Press Enter to choose model and reasoning in /config"
707
+ if subscription_selection_required
708
+ else "▶ Press Enter to start chatting"
709
+ )
710
+ ),
711
+ )
712
+
713
+ def _screen_fatal(self) -> Screen:
714
+ return Screen(
715
+ stage="fatal",
716
+ mode="message",
717
+ title="Setup failed",
718
+ lines=[(self.fatal_error or "Setup could not be completed.", "err")],
719
+ hint="▶ Press Enter to exit",
720
+ )
721
+
722
+ # ------------------------------------------------------------- summaries
723
+
724
+ def _summary_lines(self) -> list[tuple[str, Tone]]:
725
+ out: list[tuple[str, Tone]] = []
726
+ delegated = (
727
+ self.execution_result is not None and self.execution_result.backend == "delegated"
728
+ )
729
+ if delegated and self.execution_result is not None:
730
+ out.append(("Connection AI subscription", "ok"))
731
+ out.append((f"Provider {self.execution_result.label}", "plain"))
732
+ auth_summary = self.runtime_auth_summary or "not connected"
733
+ out.append(
734
+ (
735
+ f"Sign-in {auth_summary}",
736
+ "ok" if self.runtime_auth_connected else "warn",
737
+ )
738
+ )
739
+ if self.profile_result is not None:
740
+ out.append((f"Profile {self.profile_result.label} (active)", "ok"))
741
+ if self.api_key_result is not None:
742
+ out.append((f"API key {self._api_key_summary()}", self._api_key_tone()))
743
+ if self.model_result is not None:
744
+ out.append((f"Model {self.model_result.model}", "plain"))
745
+ if (
746
+ self.profile_result is not None
747
+ and self.profile_result.preset is not None
748
+ and self.profile_result.preset.key in _wiz._MODEL_REASONING_SETUP_PRESET_KEYS
749
+ ):
750
+ out.append((f"Reasoning {self.model_result.reasoning_label}", "plain"))
751
+ elif self.cfg is not None and self.cfg.execution.backend == "native":
752
+ try:
753
+ from ...profiles import active_subscription_selection_ready, get_active_profile
754
+
755
+ active = get_active_profile(self.cfg)
756
+ selection_ready = active_subscription_selection_ready(self.cfg)
757
+ except Exception:
758
+ active = None
759
+ selection_ready = True
760
+ if active is not None and active.auth_provider and not selection_ready:
761
+ out.append(("Model choose in /config → Default Model", "warn"))
762
+ out.append(("Reasoning choose with the model in /config", "warn"))
763
+ else:
764
+ out.append((f"Model {self.cfg.model}", "plain"))
765
+ if active is not None and active.reasoning_effort is not None:
766
+ out.append((f"Reasoning {active.reasoning_effort}", "plain"))
767
+ if self.workspace_result is not None:
768
+ out.append((f"Workspace {self.workspace_result.workspace}", "plain"))
769
+ if self.sandbox_result is not None:
770
+ out.append((f"Sandbox {self._sandbox_summary()}", self._sandbox_tone()))
771
+ if self.login_summary:
772
+ out.append((f"Account {self.login_summary}", "ok" if self.login_ok else "warn"))
773
+ for issue in self.diagnostic_lines:
774
+ out.append((f"Diagnostic: {issue}", "warn"))
775
+ out.append(("", "plain"))
776
+ if delegated and self.execution_result is not None:
777
+ runtime_id = str(self.execution_result.runtime or "").strip()
778
+ if self.runtime_auth_connected:
779
+ out.append(("Your AI subscription is connected.", "dim"))
780
+ out.append(
781
+ (
782
+ "Provider sign-in is immediate; leaving setup does not sign you out.",
783
+ "dim",
784
+ )
785
+ )
786
+ else:
787
+ out.append(
788
+ (
789
+ f"Next: run `alysis auth login {runtime_id}` before using this subscription.",
790
+ "warn",
791
+ )
792
+ )
793
+ if self.execution_result.auth_hint:
794
+ out.append((self.execution_result.auth_hint, "dim"))
795
+ else:
796
+ out.append(("Type your first message to start chatting.", "dim"))
797
+ return out
798
+
799
+ def _api_key_summary(self) -> str:
800
+ r = self.api_key_result
801
+ if r is None or not r.api_key:
802
+ return "not stored"
803
+ reason = (r.validation_message or "").strip()
804
+ if r.validation_status == "validated":
805
+ return "stored, validated"
806
+ if r.validation_status == "inconclusive":
807
+ return "stored, validation inconclusive" + (f" ({reason})" if reason else "")
808
+ if r.validation_status == "failed":
809
+ return "stored, but provider rejected it. Re-enter via /config."
810
+ if r.validation_status == "skipped":
811
+ return "stored, validation skipped"
812
+ if r.validation_status == "model_not_found":
813
+ return "stored, model validation failed"
814
+ return "stored"
815
+
816
+ def _api_key_tone(self) -> Tone:
817
+ r = self.api_key_result
818
+ if r is None or not r.api_key:
819
+ return "warn"
820
+ if r.validation_status == "validated":
821
+ return "ok"
822
+ if r.validation_status in {"failed", "model_not_found"}:
823
+ return "err"
824
+ return "warn"
825
+
826
+ def _sandbox_summary(self) -> str:
827
+ r = self.sandbox_result
828
+ if r is None:
829
+ return "skipped"
830
+ if r.ready:
831
+ return f"ready ({r.status})"
832
+ if r.status == "disabled":
833
+ return "off — host execution (less safe); re-enable via /config"
834
+ if r.status == "skipped":
835
+ return "skipped — run `alysis sandbox setup` to finish"
836
+ return f"{r.status} — run `alysis sandbox setup` to finish"
837
+
838
+ def _sandbox_tone(self) -> Tone:
839
+ r = self.sandbox_result
840
+ if r is not None and r.ready:
841
+ return "ok"
842
+ return "warn"
843
+
844
+ # ----------------------------------------------------------- navigation
845
+
846
+ def move(self, delta: int) -> None:
847
+ scr = self.screen()
848
+ if scr.mode != "list" or not scr.rows:
849
+ return
850
+ self.index = (self.index + delta) % len(scr.rows)
851
+
852
+ def choose_index(self, idx: int) -> None:
853
+ scr = self.screen()
854
+ if scr.mode != "list" or not scr.rows:
855
+ return
856
+ if 0 <= idx < len(scr.rows):
857
+ self.index = idx
858
+ self.choose(scr.rows[idx].value)
859
+
860
+ def choose_current(self) -> None:
861
+ scr = self.screen()
862
+ if scr.mode != "list" or not scr.rows:
863
+ return
864
+ self.choose(scr.rows[self.index].value)
865
+
866
+ def choose(self, value: str) -> None:
867
+ if self.stage == "connect_provider":
868
+ self._choose_connect_provider(value)
869
+ elif self.stage == "provider_advanced":
870
+ self._choose_provider(value)
871
+ elif self.stage == "model":
872
+ self._choose_model(value)
873
+ elif self.stage == "model_thinking":
874
+ self._choose_model_thinking(value)
875
+ elif self.stage == "sandbox_choice":
876
+ self._choose_sandbox(value)
877
+
878
+ def submit_input(self, text: str) -> None:
879
+ text = str(text)
880
+ if self.stage == "custom_name":
881
+ self._submit_custom_name(text)
882
+ elif self.stage == "custom_url":
883
+ self._submit_custom_url(text)
884
+ elif self.stage == "custom_headers":
885
+ self._submit_custom_headers(text)
886
+ elif self.stage == "api_key":
887
+ self._submit_api_key(text)
888
+ elif self.stage == "custom_model":
889
+ self._submit_custom_model(text)
890
+ elif self.stage == "workspace":
891
+ self._submit_workspace(text)
892
+
893
+ def confirm(self, yes: bool) -> None:
894
+ if self.stage == "cancel_confirm":
895
+ if yes:
896
+ self._finish(False)
897
+ else:
898
+ self._goto(self._resume_stage or "welcome")
899
+ elif self.stage == "sandbox_pull_confirm":
900
+ if yes:
901
+ self._goto("pulling_sandbox")
902
+ else:
903
+ self._sandbox_skip()
904
+ elif self.stage == "model_not_found_confirm":
905
+ if yes:
906
+ self._accept_unconfirmed_model()
907
+ else:
908
+ self._goto("model")
909
+ elif self.stage == "workspace_create_confirm":
910
+ if yes:
911
+ self._accept_workspace_path(
912
+ self._pending_workspace_path,
913
+ create_if_missing=True,
914
+ )
915
+ else:
916
+ self._pending_workspace_path = None
917
+ self._goto("workspace")
918
+ self._set_status(
919
+ "Type an existing folder, or enter a new folder and choose Create.",
920
+ "warn",
921
+ )
922
+ elif self.stage == "login_confirm":
923
+ if yes:
924
+ self._goto("logging_in")
925
+ else:
926
+ self.login_summary = ""
927
+ self._goto("complete")
928
+ elif self.stage == "runtime_login_confirm":
929
+ if yes:
930
+ self._goto("runtime_logging_in")
931
+ else:
932
+ self._skip_runtime_login()
933
+
934
+ def advance_message(self) -> None:
935
+ """Enter on a ``message`` screen."""
936
+ if self.stage == "welcome":
937
+ self._goto("connect_provider")
938
+ elif self.stage == "complete":
939
+ self._finish(True)
940
+ elif self.stage == "fatal":
941
+ self._finish(False)
942
+
943
+ def back(self) -> None:
944
+ custom_profile = self.profile_result is not None and self.profile_result.preset is None
945
+ prev = {
946
+ "connect_provider": "welcome",
947
+ "provider_advanced": "connect_provider",
948
+ "custom_name": "provider_advanced",
949
+ "custom_url": "custom_name",
950
+ "custom_headers": "custom_url",
951
+ "api_key": "custom_headers" if custom_profile else "connect_provider",
952
+ "model": "api_key",
953
+ "custom_model": "model",
954
+ "model_thinking": "model",
955
+ "workspace": (
956
+ "connect_provider"
957
+ if self.execution_result is not None
958
+ and self.execution_result.backend == "delegated"
959
+ else (
960
+ "model_thinking"
961
+ if self.model_result is not None
962
+ and self.profile_result is not None
963
+ and _wiz._setup_reasoning_labels(
964
+ self.profile_result,
965
+ model=self.model_result.model,
966
+ current=self.model_result.reasoning_label,
967
+ )
968
+ != ("auto",)
969
+ else "model"
970
+ )
971
+ ),
972
+ "workspace_create_confirm": "workspace",
973
+ }.get(self.stage)
974
+ if self.stage == "welcome":
975
+ self.request_cancel()
976
+ return
977
+ if prev is None:
978
+ # On a confirm/busy/terminal screen Esc is a soft "skip back".
979
+ if self.stage in {"sandbox_choice", "sandbox_pull_confirm"}:
980
+ self._sandbox_skip()
981
+ return
982
+ if self.stage == "login_confirm":
983
+ self.login_summary = ""
984
+ self._goto("complete")
985
+ return
986
+ if self.stage == "runtime_login_confirm":
987
+ self._skip_runtime_login()
988
+ return
989
+ if self.stage == "model_not_found_confirm":
990
+ self._goto("model")
991
+ return
992
+ return
993
+ self._set_status("", "dim")
994
+ self._goto(prev)
995
+
996
+ def request_cancel(self) -> None:
997
+ """Ctrl+C / Esc-at-welcome: confirm before discarding."""
998
+ if self.stage in {"cancel_confirm", "complete", "fatal", "done"}:
999
+ return
1000
+ self._resume_stage = self.stage
1001
+ self._goto("cancel_confirm", keep_status=True)
1002
+
1003
+ # --------------------------------------------------------- stage helpers
1004
+
1005
+ def _goto(self, stage: str, *, reset_index: bool = True, keep_status: bool = False) -> None:
1006
+ self.stage = stage
1007
+ if reset_index:
1008
+ self.index = 0
1009
+ if not keep_status:
1010
+ self._set_status("", "dim")
1011
+
1012
+ def _finish(self, success: bool) -> None:
1013
+ self.success = success
1014
+ self.stage = "done"
1015
+
1016
+ # --------------------------------------------------------- provider step
1017
+
1018
+ def _choose_connect_provider(self, value: str) -> None:
1019
+ """Dispatch a merged-picker row to the right branch.
1020
+
1021
+ Rows are either a subscription (``runtime:<id>`` → workspace, OAuth
1022
+ happens post-commit), the advanced sub-list, or an API-key/local
1023
+ provider preset (→ API key step). Switching between a subscription and
1024
+ a provider — or between different subscriptions — invalidates the
1025
+ dependent step results, mirroring the old method-first reset.
1026
+ """
1027
+ if value == _wiz._ADVANCED_PROVIDER_PRESETS_VALUE:
1028
+ self._goto("provider_advanced")
1029
+ return
1030
+ if value.startswith(_wiz._RUNTIME_EXECUTION_PREFIX):
1031
+ try:
1032
+ selected = _wiz._execution_result_from_value(value)
1033
+ except ConfigError as exc:
1034
+ self._set_status(str(exc), "err")
1035
+ return
1036
+ if self.execution_result is not None and selected != self.execution_result:
1037
+ self.profile_result = None
1038
+ self.api_key_result = None
1039
+ self.model_result = None
1040
+ self._discovered_model_options = ()
1041
+ self._model_catalog_warning = ""
1042
+ self.execution_result = selected
1043
+ self._goto("workspace")
1044
+ return
1045
+ selected = _ExecutionStepResult(backend="native")
1046
+ if self.execution_result is not None and selected != self.execution_result:
1047
+ self.profile_result = None
1048
+ self.api_key_result = None
1049
+ self.model_result = None
1050
+ self._discovered_model_options = ()
1051
+ self._model_catalog_warning = ""
1052
+ self.execution_result = selected
1053
+ self._choose_provider(value)
1054
+
1055
+ def _choose_provider(self, key: str) -> None:
1056
+ if key == _wiz._ADVANCED_PROVIDER_PRESETS_VALUE:
1057
+ self._goto("provider_advanced")
1058
+ return
1059
+ preset = _wiz._preset_by_key(key)
1060
+ if preset is None:
1061
+ self._set_status(f"Unknown provider preset: {key}", "err")
1062
+ return
1063
+ if preset.key == _wiz._CUSTOM_PROVIDER_KEY:
1064
+ self._goto("custom_name")
1065
+ return
1066
+ profile = make_profile_from_preset(preset)
1067
+ new = _ProfileStepResult(profile=profile, label=preset.label, preset=preset)
1068
+ # Re-selecting a different provider invalidates the dependent steps.
1069
+ if self.profile_result is not None and new != self.profile_result:
1070
+ self.api_key_result = None
1071
+ self.model_result = None
1072
+ self._discovered_model_options = ()
1073
+ self._model_catalog_warning = ""
1074
+ self.profile_result = new
1075
+ warning = (preset.setup_warning or "").strip()
1076
+ if self._hosted_mimo():
1077
+ # The Alysis Code account needs no API key and login picks the
1078
+ # hosted default model — skip both steps and head straight to the
1079
+ # workspace; the post-commit login_confirm step connects the
1080
+ # account in the browser.
1081
+ self.api_key_result = _ApiKeyStepResult(
1082
+ api_key="",
1083
+ validation_status="skipped",
1084
+ validation_message="Alysis Code account uses browser sign-in — no API key.",
1085
+ )
1086
+ self.model_result = _ModelStepResult(model="deepseek-v4-flash")
1087
+ self._goto("workspace")
1088
+ self._set_status(
1089
+ "Alysis Code account: no API key needed — you'll sign in after setup.",
1090
+ "ok",
1091
+ )
1092
+ return
1093
+ self._goto("api_key")
1094
+ if warning:
1095
+ self._set_status(warning, "warn")
1096
+
1097
+ def _submit_custom_name(self, text: str) -> None:
1098
+ name = text.strip().lower() or "custom"
1099
+ self._custom_name = name
1100
+ self._goto("custom_url")
1101
+
1102
+ def _submit_custom_url(self, text: str) -> None:
1103
+ url = text.strip()
1104
+ if not url:
1105
+ self._set_status("Base URL is required.", "err")
1106
+ return
1107
+ self._custom_url = url
1108
+ self._goto("custom_headers")
1109
+
1110
+ def _submit_custom_headers(self, text: str) -> None:
1111
+ headers, ok = self._parse_extra_headers(text)
1112
+ if not ok:
1113
+ self._set_status("Extra headers must use k=v syntax.", "err")
1114
+ return
1115
+ self._custom_headers = headers
1116
+ profile = ProfileSpec(
1117
+ name=self._custom_name,
1118
+ protocol="openai_compat",
1119
+ base_url=self._custom_url,
1120
+ extra_headers=headers,
1121
+ notes="Custom OpenAI-compatible endpoint.",
1122
+ )
1123
+ new = _ProfileStepResult(profile=profile, label=profile.name, preset=None)
1124
+ if self.profile_result is not None and new != self.profile_result:
1125
+ self.api_key_result = None
1126
+ self.model_result = None
1127
+ self._discovered_model_options = ()
1128
+ self._model_catalog_warning = ""
1129
+ self.profile_result = new
1130
+ self._goto("api_key")
1131
+
1132
+ @staticmethod
1133
+ def _parse_extra_headers(text: str) -> tuple[dict[str, str], bool]:
1134
+ """Parse ``k=v, k=v`` into a header dict (mirrors the classic wizard).
1135
+
1136
+ Returns ``(headers, ok)``; ``ok`` is False when an item lacks ``=`` so
1137
+ the caller can re-prompt. An empty string yields ``({}, True)``.
1138
+ """
1139
+ headers: dict[str, str] = {}
1140
+ for item in str(text).split(","):
1141
+ piece = item.strip()
1142
+ if not piece:
1143
+ continue
1144
+ if "=" not in piece:
1145
+ return {}, False
1146
+ key, value = piece.split("=", 1)
1147
+ if key.strip() and value.strip():
1148
+ headers[key.strip()] = value.strip()
1149
+ return headers, True
1150
+
1151
+ # --------------------------------------------------------- api key step
1152
+
1153
+ def _submit_api_key(self, text: str) -> None:
1154
+ profile = self.profile_result.profile # type: ignore[union-attr]
1155
+ value = text.strip()
1156
+ required = bool(profile.api_key_env)
1157
+ has_key = self.api_key_result is not None and bool(self.api_key_result.api_key)
1158
+ if not value:
1159
+ if has_key:
1160
+ # Returning to this step (e.g. Esc from model) keeps the already
1161
+ # validated key instead of forcing a re-paste — matches the
1162
+ # classic wizard's "Enter to keep current".
1163
+ self._goto("model")
1164
+ return
1165
+ if required:
1166
+ # Empty has no cap of its own (it just re-prompts), so it must not
1167
+ # consume the genuine validation-failure budget (_key_attempts).
1168
+ self._set_status("API key is required to continue.", "err")
1169
+ return
1170
+ self.api_key_result = _ApiKeyStepResult(
1171
+ api_key="", validation_status="skipped", validation_message="No API key provided."
1172
+ )
1173
+ self._goto("model")
1174
+ return
1175
+ # Stash the candidate and validate on the worker.
1176
+ self._pending_key = value
1177
+ self._goto("validating_key")
1178
+
1179
+ def _run_validating_key(self) -> None:
1180
+ value = getattr(self, "_pending_key", "")
1181
+ self._report("Validating key…")
1182
+ validation = _wiz._validate_api_key(
1183
+ profile=self.profile_result.profile, # type: ignore[union-attr]
1184
+ api_key=value,
1185
+ suggested_models=_wiz._suggested_models(self.profile_result), # type: ignore[arg-type]
1186
+ validation_model=_wiz._validation_model_hint(self.profile_result), # type: ignore[arg-type]
1187
+ )
1188
+ status = validation.status
1189
+ message = validation.message
1190
+ if status == "validated":
1191
+ self.api_key_result = _ApiKeyStepResult(
1192
+ api_key=value, validation_status="validated", validation_message=message
1193
+ )
1194
+ self._load_provider_model_catalog(value)
1195
+ self._goto("model")
1196
+ if message:
1197
+ self._set_status(message, "warn")
1198
+ return
1199
+ if status in {"inconclusive", "model_not_found"}:
1200
+ self.api_key_result = _ApiKeyStepResult(
1201
+ api_key=value, validation_status=status, validation_message=message
1202
+ )
1203
+ self._load_provider_model_catalog(value)
1204
+ self._goto("model")
1205
+ self._set_status(
1206
+ (message or "")
1207
+ + (
1208
+ " We'll verify your chosen model next."
1209
+ if status == "model_not_found"
1210
+ else " Continuing without validation."
1211
+ ),
1212
+ "warn",
1213
+ )
1214
+ return
1215
+ # Failed: allow a few retries, then continue with the last key.
1216
+ self._key_attempts += 1
1217
+ if self._key_attempts >= _MAX_KEY_ATTEMPTS:
1218
+ self.api_key_result = _ApiKeyStepResult(
1219
+ api_key=value, validation_status="failed", validation_message=message
1220
+ )
1221
+ self._discovered_model_options = ()
1222
+ self._model_catalog_warning = (
1223
+ "Live model discovery was skipped because the provider rejected the API key."
1224
+ )
1225
+ self._goto("model")
1226
+ self._set_status("Continuing with the last key. Fix it later in /config.", "warn")
1227
+ return
1228
+ self._goto("api_key")
1229
+ self._set_status(
1230
+ f"Key validation failed: {message or 'provider rejected the key'} (attempt {self._key_attempts}/{_MAX_KEY_ATTEMPTS})",
1231
+ "err",
1232
+ )
1233
+
1234
+ def _load_provider_model_catalog(self, api_key: str) -> None:
1235
+ options, warning = _wiz._discover_setup_provider_models( # type: ignore[arg-type]
1236
+ self.profile_result,
1237
+ api_key=api_key,
1238
+ )
1239
+ self._discovered_model_options = options
1240
+ self._model_catalog_warning = warning
1241
+
1242
+ # ----------------------------------------------------------- model step
1243
+
1244
+ def _choose_model(self, value: str) -> None:
1245
+ if value == _wiz._CUSTOM_MODEL_VALUE:
1246
+ self._goto("custom_model")
1247
+ return
1248
+ self._set_model(value, custom=False)
1249
+
1250
+ def _submit_custom_model(self, text: str) -> None:
1251
+ model = text.strip()
1252
+ if not model:
1253
+ self._set_status("Model is required.", "err")
1254
+ return
1255
+ self._set_model(model, custom=True)
1256
+
1257
+ def _set_model(self, model: str, *, custom: bool) -> None:
1258
+ preset = self.profile_result.preset if self.profile_result else None
1259
+ if preset is not None:
1260
+ model = canonical_model_alias_for_preset(preset, model)
1261
+ model = model.strip()
1262
+ if not model:
1263
+ self._set_status("Default model is required.", "err")
1264
+ return
1265
+ self.model_result = _ModelStepResult(model=model, custom=custom)
1266
+ self._goto("validating_model")
1267
+
1268
+ def _advance_after_model_validation(self) -> None:
1269
+ if self.model_result is None or self.profile_result is None:
1270
+ self._goto("workspace")
1271
+ return
1272
+ labels = _wiz._setup_reasoning_labels(
1273
+ self.profile_result,
1274
+ model=self.model_result.model,
1275
+ current=self.model_result.reasoning_label,
1276
+ )
1277
+ if labels == ("auto",):
1278
+ self.model_result = replace(self.model_result, reasoning_label="auto")
1279
+ self._goto("workspace")
1280
+ preset = self.profile_result.preset
1281
+ if preset is not None and preset.key == _wiz._NVIDIA_PRESET_KEY:
1282
+ self._set_status(
1283
+ "No verified reasoning control is known for this NVIDIA-hosted model; "
1284
+ "the provider default will be used.",
1285
+ "warn",
1286
+ )
1287
+ return
1288
+ self._goto("model_thinking")
1289
+
1290
+ def _choose_model_thinking(self, value: str) -> None:
1291
+ if self.model_result is None:
1292
+ self._goto("model")
1293
+ return
1294
+ labels = _wiz._setup_reasoning_labels( # type: ignore[arg-type]
1295
+ self.profile_result,
1296
+ model=self.model_result.model,
1297
+ current=self.model_result.reasoning_label,
1298
+ )
1299
+ if value not in labels:
1300
+ self._set_status("That reasoning effort is not supported by this model.", "err")
1301
+ return
1302
+ self.model_result = replace(self.model_result, reasoning_label=value)
1303
+ self._goto("workspace")
1304
+
1305
+ def _run_validating_model(self) -> None:
1306
+ key = self.api_key_result
1307
+ if key is None or not key.api_key or key.validation_status == "failed":
1308
+ # Nothing to validate against; accept the model and move on.
1309
+ self._advance_after_model_validation()
1310
+ return
1311
+ self._report("Validating model…")
1312
+ validation = _wiz._validate_api_key(
1313
+ profile=self.profile_result.profile, # type: ignore[union-attr]
1314
+ api_key=key.api_key,
1315
+ model=self.model_result.model, # type: ignore[union-attr]
1316
+ suggested_models=_wiz._suggested_models(self.profile_result), # type: ignore[arg-type]
1317
+ )
1318
+ self.api_key_result = replace(
1319
+ key, validation_status=validation.status, validation_message=validation.message
1320
+ )
1321
+ if validation.status == "model_not_found":
1322
+ self._last_model_not_found = self.model_result.model # type: ignore[union-attr]
1323
+ if self.model_result is not None and self.model_result.custom:
1324
+ self._goto("model_not_found_confirm")
1325
+ else:
1326
+ self._goto("model")
1327
+ self._set_status(validation.message or "Model not found at this provider.", "err")
1328
+ return
1329
+ if validation.status == "validated":
1330
+ self._advance_after_model_validation()
1331
+ self._set_status("Model validated.", "ok")
1332
+ return
1333
+ if validation.status == "failed":
1334
+ self._advance_after_model_validation()
1335
+ self._set_status(validation.message or "Provider rejected the key.", "err")
1336
+ return
1337
+ self._advance_after_model_validation()
1338
+ if validation.message:
1339
+ self._set_status(validation.message, "warn")
1340
+
1341
+ def _accept_unconfirmed_model(self) -> None:
1342
+ warning = (
1343
+ f"Model '{self._last_model_not_found}' was not confirmed; "
1344
+ "provider reported it missing and you chose to use it anyway."
1345
+ )
1346
+ if self.api_key_result is not None:
1347
+ self.api_key_result = replace(
1348
+ self.api_key_result, validation_status="inconclusive", validation_message=warning
1349
+ )
1350
+ self._advance_after_model_validation()
1351
+ self._set_status(warning, "warn")
1352
+
1353
+ # ------------------------------------------------------- workspace step
1354
+
1355
+ def _submit_workspace(self, text: str) -> None:
1356
+ default = _wiz._suggest_workspace_default()
1357
+ raw = text.strip() or os.fspath(default)
1358
+ selected = Path(raw).expanduser().resolve(strict=False)
1359
+ if not selected.exists():
1360
+ self._pending_workspace_path = selected
1361
+ self._goto("workspace_create_confirm")
1362
+ return
1363
+ self._accept_workspace_path(selected, create_if_missing=False)
1364
+
1365
+ def _accept_workspace_path(
1366
+ self,
1367
+ selected: Path | None,
1368
+ *,
1369
+ create_if_missing: bool,
1370
+ ) -> None:
1371
+ if selected is None:
1372
+ self._goto("workspace")
1373
+ self._set_status("Choose a workspace folder.", "err")
1374
+ return
1375
+ try:
1376
+ binding = resolve_workspace_binding(
1377
+ selected,
1378
+ create_if_missing=create_if_missing,
1379
+ allow_broad_workspace=True,
1380
+ source="setup_wizard",
1381
+ )
1382
+ except WorkspaceBindingError as exc:
1383
+ self._goto("workspace")
1384
+ self._set_status(self._workspace_error_message(selected, exc), "err")
1385
+ return
1386
+ self.workspace_result = _WorkspaceStepResult(workspace=os.fspath(binding.requested_path))
1387
+ self._pending_workspace_path = None
1388
+ self._goto("committing")
1389
+
1390
+ @staticmethod
1391
+ def _workspace_error_message(selected: Path, exc: WorkspaceBindingError) -> str:
1392
+ message = str(exc)
1393
+ if "create_if_missing=True" in message:
1394
+ return f"Folder does not exist: {selected}. Type an existing folder or choose Create."
1395
+ if "not a directory" in message:
1396
+ return f"That path is not a folder: {selected}"
1397
+ return message
1398
+
1399
+ # ---------------------------------------------------------- commit step
1400
+
1401
+ def _run_committing(self) -> None:
1402
+ self._report("Saving setup…")
1403
+ sink = _RichConsole(file=io.StringIO(), force_terminal=False, no_color=True)
1404
+ try:
1405
+ if self.execution_result is not None and self.execution_result.backend == "delegated":
1406
+ cfg = _wiz._commit_delegated_setup(
1407
+ execution_result=self.execution_result,
1408
+ workspace_result=self.workspace_result, # type: ignore[arg-type]
1409
+ console=sink,
1410
+ )
1411
+ else:
1412
+ cfg = _wiz._commit_setup(
1413
+ profile_result=self.profile_result, # type: ignore[arg-type]
1414
+ api_key_result=self.api_key_result, # type: ignore[arg-type]
1415
+ model_result=self.model_result, # type: ignore[arg-type]
1416
+ workspace_result=self.workspace_result, # type: ignore[arg-type]
1417
+ console=sink,
1418
+ )
1419
+ except (ConfigError, OSError) as exc:
1420
+ self.fatal_error = f"Failed to save setup: {exc}"
1421
+ self._goto("fatal")
1422
+ return
1423
+ self.cfg = cfg
1424
+ if self.execution_result is not None and self.execution_result.backend == "delegated":
1425
+ self.diagnostic_lines = []
1426
+ self.sandbox_result = None
1427
+ self._goto("checking_runtime_auth")
1428
+ return
1429
+ self.diagnostic_lines = self._collect_diagnostics(cfg)
1430
+ self._goto("diagnosing_sandbox")
1431
+
1432
+ def _collect_diagnostics(self, cfg: AppConfig) -> list[str]:
1433
+ try:
1434
+ from ...provider_diagnostics import provider_diagnostic_warning_lines
1435
+
1436
+ return list(provider_diagnostic_warning_lines(cfg))
1437
+ except Exception:
1438
+ return []
1439
+
1440
+ # ------------------------------------------------ runtime account step
1441
+
1442
+ def _run_checking_runtime_auth(self) -> None:
1443
+ self._report("Checking subscription connection…")
1444
+ result: _DelegatedAuthResult = _wiz._check_delegated_runtime_connection(
1445
+ self.cfg, # type: ignore[arg-type]
1446
+ self.execution_result, # type: ignore[arg-type]
1447
+ )
1448
+ self.runtime_auth_connected = result.connected
1449
+ self.runtime_auth_summary = result.summary
1450
+ if result.connected and self.cfg is not None and self.cfg.execution.backend == "native":
1451
+ try:
1452
+ _wiz._sync_direct_subscription_model(
1453
+ self.cfg,
1454
+ str(self.execution_result.runtime or ""), # type: ignore[union-attr]
1455
+ )
1456
+ except Exception as exc: # noqa: BLE001
1457
+ self._goto("runtime_login_confirm")
1458
+ self._set_status(f"Connected, but model discovery failed: {exc}", "err")
1459
+ return
1460
+ self.diagnostic_lines = self._collect_diagnostics(self.cfg)
1461
+ self._goto("diagnosing_sandbox")
1462
+ return
1463
+ self._goto("complete" if result.connected else "runtime_login_confirm")
1464
+
1465
+ def _skip_runtime_login(self) -> None:
1466
+ self.runtime_auth_connected = False
1467
+ if not self.runtime_auth_summary:
1468
+ self.runtime_auth_summary = "not connected"
1469
+ self._goto("complete")
1470
+
1471
+ def _run_runtime_logging_in(self) -> None:
1472
+ self._report("Opening provider sign-in…")
1473
+ result: _DelegatedAuthResult = _wiz._login_delegated_runtime(
1474
+ self.cfg, # type: ignore[arg-type]
1475
+ self.execution_result, # type: ignore[arg-type]
1476
+ )
1477
+ self.runtime_auth_connected = result.connected
1478
+ self.runtime_auth_summary = result.summary
1479
+ if result.connected and self.cfg is not None and self.cfg.execution.backend == "native":
1480
+ self.diagnostic_lines = self._collect_diagnostics(self.cfg)
1481
+ self._goto("diagnosing_sandbox")
1482
+ else:
1483
+ self._goto("complete")
1484
+
1485
+ # --------------------------------------------------------- sandbox step
1486
+
1487
+ def _run_diagnosing_sandbox(self) -> None:
1488
+ from ...sandbox_doctor import detect_bubblewrap_install_plan, diagnose_sandbox
1489
+
1490
+ self._report("Checking sandbox readiness…")
1491
+ try:
1492
+ result = diagnose_sandbox(self.cfg, include_smoke=False)
1493
+ except Exception as exc: # noqa: BLE001 - backends fail in environment-specific ways
1494
+ self.sandbox_result = _SandboxStepResult(ready=False, status="check failed")
1495
+ self._after_sandbox()
1496
+ self._set_status(f"Sandbox check failed: {exc}", "warn")
1497
+ return
1498
+ self._sandbox_diag = result
1499
+ if result.ready:
1500
+ self.sandbox_result = _SandboxStepResult(
1501
+ ready=True, status=result.selected_backend or result.status
1502
+ )
1503
+ self._after_sandbox()
1504
+ return
1505
+ if result.can_pull:
1506
+ self._goto("sandbox_pull_confirm")
1507
+ return
1508
+ self._sandbox_plan = detect_bubblewrap_install_plan()
1509
+ self._goto("sandbox_choice")
1510
+
1511
+ def _choose_sandbox(self, value: str) -> None:
1512
+ if value == "install_bwrap":
1513
+ self._goto("installing_sandbox")
1514
+ elif value == "disable":
1515
+ self._goto("disabling_sandbox")
1516
+ else:
1517
+ self._sandbox_skip()
1518
+
1519
+ def _sandbox_skip(self) -> None:
1520
+ self.sandbox_result = _SandboxStepResult(ready=False, status="not ready")
1521
+ self._after_sandbox()
1522
+
1523
+ def _run_disabling_sandbox(self) -> None:
1524
+ from ...config import save_config
1525
+ from ...sandbox_settings import apply_sandbox_mode_to_config
1526
+
1527
+ apply_sandbox_mode_to_config(self.cfg, "off")
1528
+ try:
1529
+ save_config(self.cfg)
1530
+ self.sandbox_result = _SandboxStepResult(ready=False, status="disabled")
1531
+ except (ConfigError, OSError) as exc:
1532
+ self.sandbox_result = _SandboxStepResult(ready=False, status="not ready")
1533
+ self._set_status(f"Could not save the sandbox setting: {exc}", "warn")
1534
+ self._after_sandbox()
1535
+
1536
+ def _run_installing_sandbox(self) -> None:
1537
+ from ...sandbox_doctor import diagnose_sandbox, install_bubblewrap
1538
+
1539
+ self._report("Installing bubblewrap…")
1540
+ install = install_bubblewrap(plan=self._sandbox_plan)
1541
+ if not install.ok:
1542
+ self.sandbox_result = _SandboxStepResult(ready=False, status="not ready")
1543
+ self._after_sandbox()
1544
+ self._set_status(f"Bubblewrap install did not complete: {install.detail}", "warn")
1545
+ return
1546
+ self._recheck_sandbox(diagnose_sandbox)
1547
+
1548
+ def _run_pulling_sandbox(self) -> None:
1549
+ from ...sandbox_doctor import diagnose_sandbox, pull_sandbox_images
1550
+
1551
+ self._report("Pulling sandbox image…")
1552
+ try:
1553
+ pull = pull_sandbox_images(timeout_s=900)
1554
+ except Exception as exc: # noqa: BLE001
1555
+ self.sandbox_result = _SandboxStepResult(ready=False, status="pull failed")
1556
+ self._after_sandbox()
1557
+ self._set_status(f"Sandbox image pull failed: {exc}", "warn")
1558
+ return
1559
+ if not pull.ok:
1560
+ self.sandbox_result = _SandboxStepResult(ready=False, status="pull failed")
1561
+ self._after_sandbox()
1562
+ self._set_status(
1563
+ f"Sandbox image pull failed: {pull.error or 'image pull failed'}", "warn"
1564
+ )
1565
+ return
1566
+ self._recheck_sandbox(diagnose_sandbox)
1567
+
1568
+ def _recheck_sandbox(self, diagnose_sandbox: Callable[..., Any]) -> None:
1569
+ try:
1570
+ result = diagnose_sandbox(self.cfg, include_smoke=True)
1571
+ except Exception as exc: # noqa: BLE001
1572
+ self.sandbox_result = _SandboxStepResult(ready=False, status="check failed")
1573
+ self._after_sandbox()
1574
+ self._set_status(f"Sandbox check failed after setup: {exc}", "warn")
1575
+ return
1576
+ if result.ready:
1577
+ self.sandbox_result = _SandboxStepResult(
1578
+ ready=True, status=result.selected_backend or result.status
1579
+ )
1580
+ else:
1581
+ self.sandbox_result = _SandboxStepResult(ready=False, status="not ready")
1582
+ self._after_sandbox()
1583
+
1584
+ def _after_sandbox(self) -> None:
1585
+ if self._hosted_mimo():
1586
+ self._goto("login_confirm")
1587
+ else:
1588
+ self._goto("complete")
1589
+
1590
+ # ----------------------------------------------------------- login step
1591
+
1592
+ def _run_logging_in(self) -> None:
1593
+ from ... import account_login
1594
+
1595
+ self._report("Connecting…")
1596
+ captured: list[str] = []
1597
+ try:
1598
+ result = account_login.login(self.cfg, output_write=lambda m: captured.append(str(m)))
1599
+ except Exception as exc: # noqa: BLE001 - login failures are non-fatal here
1600
+ # Non-fatal: setup is already saved; the summary row (in warn tone)
1601
+ # carries the message, so we don't also raise a redundant status line.
1602
+ self.login_ok = False
1603
+ self.login_summary = f"not connected ({exc}). Run `alysis login` later."
1604
+ self._goto("complete")
1605
+ return
1606
+ who = f" as {result.email}" if getattr(result, "email", None) else ""
1607
+ self.login_ok = True
1608
+ self.login_summary = f"connected{who} — Alysis Code account ready"
1609
+ self._goto("complete")
1610
+
1611
+ # ----------------------------------------------------------- busy driver
1612
+
1613
+ def run_busy(self) -> None:
1614
+ """Execute the current ``busy`` stage's blocking work (worker/terminal).
1615
+
1616
+ Each handler transitions :attr:`stage` to the next screen before
1617
+ returning, so the application only needs to re-render afterwards.
1618
+ """
1619
+ handler = getattr(self, f"_run_{self.stage}", None)
1620
+ if handler is None:
1621
+ return
1622
+ handler()