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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- 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"]
|