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,1555 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rich.console import Console, Group
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.prompt import Prompt
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from ..approval_scope import approval_session_scope_for_request
|
|
18
|
+
from ..interactive_input_guard import interactive_prompt_guard
|
|
19
|
+
from ..llm_error_display import classify_llm_error_display, friendly_llm_error_message
|
|
20
|
+
from ..plan_mode import extract_approved_plan_user_message
|
|
21
|
+
from ..subagent_labels import subagent_identity
|
|
22
|
+
from ..tools.registry import (
|
|
23
|
+
summarize_tool_output_chunk as _summarize_tool_output,
|
|
24
|
+
)
|
|
25
|
+
from ..tools.registry import (
|
|
26
|
+
tool_display_name as _tool_display_name,
|
|
27
|
+
)
|
|
28
|
+
from ..tools.registry import (
|
|
29
|
+
tool_input_preview as _tool_input_preview,
|
|
30
|
+
)
|
|
31
|
+
from .console import (
|
|
32
|
+
ascii_sanitize,
|
|
33
|
+
console_glyph,
|
|
34
|
+
console_uses_ascii,
|
|
35
|
+
make_console,
|
|
36
|
+
safe_plain_error,
|
|
37
|
+
safe_write,
|
|
38
|
+
)
|
|
39
|
+
from .events import (
|
|
40
|
+
ConfigFormRequest,
|
|
41
|
+
ErrorRaised,
|
|
42
|
+
Event,
|
|
43
|
+
InfoEmitted,
|
|
44
|
+
MessageDelta,
|
|
45
|
+
MessageEnd,
|
|
46
|
+
ModeChanged,
|
|
47
|
+
PersonaChanged,
|
|
48
|
+
PlanNodeUpdated,
|
|
49
|
+
PromptForInput,
|
|
50
|
+
ReviewGateDecision,
|
|
51
|
+
StatusUpdate,
|
|
52
|
+
SwarmWorkerStateChanged,
|
|
53
|
+
ToolCallCompleted,
|
|
54
|
+
ToolCallProgress,
|
|
55
|
+
ToolCallStarted,
|
|
56
|
+
VerifyGateResult,
|
|
57
|
+
WarningEmitted,
|
|
58
|
+
)
|
|
59
|
+
from .styles import (
|
|
60
|
+
STYLE_CHROME,
|
|
61
|
+
STYLE_CONTENT,
|
|
62
|
+
STYLE_DIM,
|
|
63
|
+
STYLE_EMPHASIS,
|
|
64
|
+
STYLE_ERROR,
|
|
65
|
+
STYLE_SUBAGENT,
|
|
66
|
+
STYLE_SUCCESS,
|
|
67
|
+
STYLE_WARN,
|
|
68
|
+
)
|
|
69
|
+
from .types import (
|
|
70
|
+
ApprovalDecision,
|
|
71
|
+
ApprovalRequest,
|
|
72
|
+
PatchEvent,
|
|
73
|
+
StatusEvent,
|
|
74
|
+
SubagentEndEvent,
|
|
75
|
+
SubagentStartEvent,
|
|
76
|
+
ToolEndEvent,
|
|
77
|
+
ToolOutputEvent,
|
|
78
|
+
ToolStartEvent,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
_SENSITIVE_PATTERNS = [
|
|
82
|
+
re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
|
|
83
|
+
re.compile(r"(Bearer\s+)[A-Za-z0-9._\-]{8,}", re.IGNORECASE),
|
|
84
|
+
re.compile(r"(Authorization\s*:\s*)(.+)", re.IGNORECASE),
|
|
85
|
+
]
|
|
86
|
+
_TRACE_LEVELS = {"off", "compact", "full"}
|
|
87
|
+
_MAX_ERROR_CHARS = 520
|
|
88
|
+
_MAX_REASONING_SUMMARY_PREVIEW_CHARS = 180
|
|
89
|
+
_STYLE_EMPHASIS = STYLE_EMPHASIS
|
|
90
|
+
_STYLE_CONTENT = STYLE_CONTENT
|
|
91
|
+
_STYLE_META = STYLE_DIM
|
|
92
|
+
_STYLE_CHROME = STYLE_CHROME
|
|
93
|
+
_STYLE_SUBAGENT = STYLE_SUBAGENT
|
|
94
|
+
_STYLE_SUCCESS = STYLE_SUCCESS
|
|
95
|
+
_STYLE_FAILURE = STYLE_ERROR
|
|
96
|
+
_STYLE_WARNING = STYLE_WARN
|
|
97
|
+
_THINKING_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
98
|
+
_TERMINAL_PROGRESS_MESSAGES = {
|
|
99
|
+
"Applied planner update to the Forge plan.",
|
|
100
|
+
"Plan draft ready for review.",
|
|
101
|
+
"Planner response ready.",
|
|
102
|
+
"Planner update was a no-op.",
|
|
103
|
+
}
|
|
104
|
+
_TERMINAL_PROGRESS_PREFIXES = (
|
|
105
|
+
"Plan generation failed:",
|
|
106
|
+
"Planner request recovered after",
|
|
107
|
+
"Planner returned an error",
|
|
108
|
+
"Swarm aborted:",
|
|
109
|
+
"Swarm completed with exit code",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _strip_progress_scope_prefix(message: str) -> str:
|
|
114
|
+
clean = str(message or "").strip()
|
|
115
|
+
if not clean.startswith("["):
|
|
116
|
+
return clean
|
|
117
|
+
closing = clean.find("]")
|
|
118
|
+
if closing <= 1:
|
|
119
|
+
return clean
|
|
120
|
+
return clean[closing + 1 :].strip()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _redact(text: str) -> str:
|
|
124
|
+
out = text
|
|
125
|
+
for pattern in _SENSITIVE_PATTERNS:
|
|
126
|
+
if pattern.pattern.lower().startswith("(authorization"):
|
|
127
|
+
out = pattern.sub(r"\1[REDACTED]", out)
|
|
128
|
+
continue
|
|
129
|
+
if pattern.pattern.lower().startswith("(bearer"):
|
|
130
|
+
out = pattern.sub(r"\1[REDACTED]", out)
|
|
131
|
+
continue
|
|
132
|
+
out = pattern.sub("[REDACTED]", out)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _truncate_inline(text: str, *, max_chars: int = 96) -> str:
|
|
137
|
+
normalized = " ".join(text.split())
|
|
138
|
+
if len(normalized) <= max_chars:
|
|
139
|
+
return normalized
|
|
140
|
+
if max_chars <= 3:
|
|
141
|
+
return normalized[:max_chars]
|
|
142
|
+
return normalized[: max_chars - 3] + "..."
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _split_lines(text: str) -> list[str]:
|
|
146
|
+
lines = str(text).splitlines()
|
|
147
|
+
return lines or [str(text)]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _looks_like_markdown(text: str) -> bool:
|
|
151
|
+
clean = str(text or "")
|
|
152
|
+
if not clean.strip():
|
|
153
|
+
return False
|
|
154
|
+
if "```" in clean:
|
|
155
|
+
return True
|
|
156
|
+
has_multiple_lines = "\n" in clean
|
|
157
|
+
for line in clean.splitlines():
|
|
158
|
+
stripped = line.lstrip()
|
|
159
|
+
if not stripped:
|
|
160
|
+
continue
|
|
161
|
+
if stripped.startswith(("#", ">", "-", "*")):
|
|
162
|
+
return True
|
|
163
|
+
if re.match(r"\d+\.\s", stripped):
|
|
164
|
+
return True
|
|
165
|
+
if has_multiple_lines and stripped.count("|") >= 2:
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _prefixed_text(
|
|
171
|
+
*,
|
|
172
|
+
prefix: str,
|
|
173
|
+
prefix_style: str,
|
|
174
|
+
text: str,
|
|
175
|
+
text_style: str,
|
|
176
|
+
) -> Text:
|
|
177
|
+
return Text.assemble((prefix, prefix_style), (text, text_style))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _bar_prefix(console: Console) -> str:
|
|
181
|
+
return console_glyph(console, "│ ", "| ")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _subagent_top_prefix(console: Console, *, indent: str) -> str:
|
|
185
|
+
return indent + console_glyph(console, "╭─ ", "+- ")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _subagent_bottom_prefix(console: Console, *, indent: str) -> str:
|
|
189
|
+
return indent + console_glyph(console, "╰─ ", "+- ")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _subagent_bar_prefix(console: Console, *, indent: str) -> str:
|
|
193
|
+
return indent + console_glyph(console, "│ ", "| ")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _separator(console: Console) -> str:
|
|
197
|
+
return console_glyph(console, " · ", " - ")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _surface_text(console: Console, text: str) -> str:
|
|
201
|
+
if not console_uses_ascii(console):
|
|
202
|
+
return str(text)
|
|
203
|
+
return ascii_sanitize(str(text), stream=getattr(console, "file", None))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _error_renderable(err: str) -> Group:
|
|
207
|
+
clean = _redact(friendly_llm_error_message(err).strip())
|
|
208
|
+
if not clean:
|
|
209
|
+
clean = "No additional error details."
|
|
210
|
+
if len(clean) > _MAX_ERROR_CHARS:
|
|
211
|
+
clean = clean[: _MAX_ERROR_CHARS - 15].rstrip() + "...(truncated)"
|
|
212
|
+
|
|
213
|
+
display = classify_llm_error_display(clean)
|
|
214
|
+
renderables: list[Text] = [
|
|
215
|
+
_prefixed_text(
|
|
216
|
+
prefix="│ ",
|
|
217
|
+
prefix_style="red",
|
|
218
|
+
text=clean,
|
|
219
|
+
text_style="red",
|
|
220
|
+
)
|
|
221
|
+
]
|
|
222
|
+
renderables.extend(
|
|
223
|
+
_prefixed_text(
|
|
224
|
+
prefix=" ",
|
|
225
|
+
prefix_style=_STYLE_CONTENT,
|
|
226
|
+
text=line,
|
|
227
|
+
text_style=_STYLE_META,
|
|
228
|
+
)
|
|
229
|
+
for line in display.guidance_lines
|
|
230
|
+
)
|
|
231
|
+
return Group(*renderables)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _warning_renderable(warning: str) -> Group:
|
|
235
|
+
clean = _redact(str(warning).strip())
|
|
236
|
+
if not clean:
|
|
237
|
+
clean = "No additional warning details."
|
|
238
|
+
if len(clean) > _MAX_ERROR_CHARS:
|
|
239
|
+
clean = clean[: _MAX_ERROR_CHARS - 15].rstrip() + "...(truncated)"
|
|
240
|
+
return Group(
|
|
241
|
+
_prefixed_text(
|
|
242
|
+
prefix="│ ",
|
|
243
|
+
prefix_style=_STYLE_WARNING,
|
|
244
|
+
text=f"Warning: {clean}",
|
|
245
|
+
text_style=_STYLE_WARNING,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _normalize_trace_level(value: str | None, *, fallback: str = "compact") -> str:
|
|
251
|
+
raw = (value or "").strip().lower()
|
|
252
|
+
if raw in _TRACE_LEVELS:
|
|
253
|
+
return raw
|
|
254
|
+
return fallback
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _format_duration_ms(elapsed_ms: int) -> str:
|
|
258
|
+
if elapsed_ms < 1000:
|
|
259
|
+
return f"{elapsed_ms}ms"
|
|
260
|
+
seconds = elapsed_ms / 1000.0
|
|
261
|
+
return f"{seconds:.1f}s"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _format_step_count(steps_completed: int) -> str:
|
|
265
|
+
step_word = "step" if steps_completed == 1 else "steps"
|
|
266
|
+
return f"{steps_completed} {step_word}"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _format_turn_elapsed(elapsed_s: float) -> str | None:
|
|
270
|
+
if elapsed_s < 1.0:
|
|
271
|
+
return None
|
|
272
|
+
total_seconds = max(int(elapsed_s), 1)
|
|
273
|
+
minutes, seconds = divmod(total_seconds, 60)
|
|
274
|
+
if minutes <= 0:
|
|
275
|
+
return f"{seconds}s"
|
|
276
|
+
if seconds <= 0:
|
|
277
|
+
return f"{minutes}m"
|
|
278
|
+
return f"{minutes}m {seconds}s"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _progress_message_completes_activity(message: str) -> bool:
|
|
282
|
+
clean = _strip_progress_scope_prefix(message)
|
|
283
|
+
if clean in _TERMINAL_PROGRESS_MESSAGES:
|
|
284
|
+
return True
|
|
285
|
+
return any(clean.startswith(prefix) for prefix in _TERMINAL_PROGRESS_PREFIXES)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _ThinkingSpinnerRenderable:
|
|
289
|
+
def __init__(self, surface: RichSurface) -> None:
|
|
290
|
+
self._surface = surface
|
|
291
|
+
|
|
292
|
+
def __rich__(self) -> Text:
|
|
293
|
+
return self._surface._make_thinking_renderable()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class RichSurface:
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
*,
|
|
300
|
+
console: Console | None = None,
|
|
301
|
+
show_status_line: bool = True,
|
|
302
|
+
) -> None:
|
|
303
|
+
self.console = console or make_console()
|
|
304
|
+
self.renders_error_panel = True
|
|
305
|
+
self._show_status_line = show_status_line
|
|
306
|
+
self._assistant_stream_open = False
|
|
307
|
+
self._allow_for_session: set[str] = set()
|
|
308
|
+
self._thinking_open = False
|
|
309
|
+
self._last_trace_line_key: str = ""
|
|
310
|
+
self._tool_output_summary: dict[str, str] = {}
|
|
311
|
+
self._tool_start_info: dict[str, tuple[int, str]] = {}
|
|
312
|
+
self._trace_level = "compact"
|
|
313
|
+
self._turn_started = False
|
|
314
|
+
self._working_banner_shown = False
|
|
315
|
+
self._turn_start_time: float | None = None
|
|
316
|
+
self._thinking_live: Live | None = None
|
|
317
|
+
self._thinking_start: float = 0.0
|
|
318
|
+
self._spinner_frame: int = 0
|
|
319
|
+
self._spinner_label: str = "Thinking..."
|
|
320
|
+
self._spinner_active = False
|
|
321
|
+
self._stream_buffer: list[str] = []
|
|
322
|
+
self._reasoning_summary_buffer: list[str] = []
|
|
323
|
+
self._reasoning_summary_live: Live | None = None
|
|
324
|
+
self._reasoning_summary_block_id: str | None = None
|
|
325
|
+
self._event_tool_names: dict[str, str] = {}
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def trace_level(self) -> str:
|
|
329
|
+
return self._trace_level
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def reasoning_trace_enabled(self) -> bool:
|
|
333
|
+
"""Return whether safe provider-generated summaries are visible."""
|
|
334
|
+
|
|
335
|
+
return self._trace_level != "off"
|
|
336
|
+
|
|
337
|
+
def set_trace_level(self, level: str) -> str:
|
|
338
|
+
self._trace_level = _normalize_trace_level(level, fallback=self._trace_level)
|
|
339
|
+
if self._trace_level == "off":
|
|
340
|
+
self._stop_reasoning_summary_live()
|
|
341
|
+
self._reasoning_summary_buffer.clear()
|
|
342
|
+
self._reasoning_summary_block_id = None
|
|
343
|
+
return self._trace_level
|
|
344
|
+
|
|
345
|
+
def _emit_trace_line(
|
|
346
|
+
self,
|
|
347
|
+
message: str,
|
|
348
|
+
*,
|
|
349
|
+
style: str,
|
|
350
|
+
prefix: str,
|
|
351
|
+
prefix_style: str,
|
|
352
|
+
dedupe_key: str | None = None,
|
|
353
|
+
) -> None:
|
|
354
|
+
self._stop_thinking_spinner()
|
|
355
|
+
self._stop_reasoning_summary_live()
|
|
356
|
+
clean = _redact(message.strip())
|
|
357
|
+
if not clean:
|
|
358
|
+
return
|
|
359
|
+
prefix = _surface_text(self.console, prefix)
|
|
360
|
+
clean = _surface_text(self.console, clean)
|
|
361
|
+
line_key = dedupe_key or f"{prefix}\0{clean}"
|
|
362
|
+
if line_key == self._last_trace_line_key:
|
|
363
|
+
return
|
|
364
|
+
self._last_trace_line_key = line_key
|
|
365
|
+
self._thinking_open = True
|
|
366
|
+
self.console.print(
|
|
367
|
+
Text.assemble((prefix, prefix_style), (clean, style)),
|
|
368
|
+
highlight=False,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _emit_thinking(self, message: str, *, style: str = "dim") -> None:
|
|
372
|
+
self._emit_trace_line(
|
|
373
|
+
message,
|
|
374
|
+
style=style,
|
|
375
|
+
prefix=console_glyph(self.console, "• ", "* "),
|
|
376
|
+
prefix_style=_STYLE_CHROME,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def _reset_thinking(self) -> None:
|
|
380
|
+
self._stop_thinking_spinner()
|
|
381
|
+
self._stop_reasoning_summary_live()
|
|
382
|
+
self._thinking_open = False
|
|
383
|
+
self._last_trace_line_key = ""
|
|
384
|
+
self._stream_buffer.clear()
|
|
385
|
+
self._reasoning_summary_buffer.clear()
|
|
386
|
+
self._reasoning_summary_block_id = None
|
|
387
|
+
|
|
388
|
+
def _flush_reasoning_summary(self) -> None:
|
|
389
|
+
self._stop_reasoning_summary_live()
|
|
390
|
+
if not self._reasoning_summary_buffer:
|
|
391
|
+
return
|
|
392
|
+
summary = _redact("".join(self._reasoning_summary_buffer)).strip()
|
|
393
|
+
self._reasoning_summary_buffer.clear()
|
|
394
|
+
self._reasoning_summary_block_id = None
|
|
395
|
+
if self._trace_level == "off" or not summary:
|
|
396
|
+
return
|
|
397
|
+
if self._trace_level == "compact":
|
|
398
|
+
preview = _truncate_inline(
|
|
399
|
+
summary,
|
|
400
|
+
max_chars=_MAX_REASONING_SUMMARY_PREVIEW_CHARS,
|
|
401
|
+
)
|
|
402
|
+
self._emit_thinking(f"Reasoning summary: {preview}", style=_STYLE_META)
|
|
403
|
+
return
|
|
404
|
+
self._emit_thinking("Reasoning summary:", style=_STYLE_CHROME)
|
|
405
|
+
for line in _split_lines(summary):
|
|
406
|
+
self._emit_thinking(line, style=_STYLE_META)
|
|
407
|
+
|
|
408
|
+
def _reasoning_summary_renderable(self) -> Text:
|
|
409
|
+
summary = _redact("".join(self._reasoning_summary_buffer)).strip()
|
|
410
|
+
if self._trace_level == "compact":
|
|
411
|
+
summary = _truncate_inline(
|
|
412
|
+
summary,
|
|
413
|
+
max_chars=_MAX_REASONING_SUMMARY_PREVIEW_CHARS,
|
|
414
|
+
)
|
|
415
|
+
label = "Reasoning summary… " if summary else "Reasoning summary…"
|
|
416
|
+
return Text.assemble(
|
|
417
|
+
(console_glyph(self.console, "• ", "* "), _STYLE_CHROME),
|
|
418
|
+
(label, _STYLE_CHROME),
|
|
419
|
+
(_surface_text(self.console, summary), _STYLE_META),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def _update_reasoning_summary_live(self) -> None:
|
|
423
|
+
if not bool(getattr(self.console, "is_terminal", False)):
|
|
424
|
+
return
|
|
425
|
+
self._stop_thinking_spinner()
|
|
426
|
+
renderable = self._reasoning_summary_renderable()
|
|
427
|
+
if self._reasoning_summary_live is None:
|
|
428
|
+
self._reasoning_summary_live = Live(
|
|
429
|
+
renderable,
|
|
430
|
+
console=self.console,
|
|
431
|
+
auto_refresh=False,
|
|
432
|
+
transient=True,
|
|
433
|
+
)
|
|
434
|
+
self._reasoning_summary_live.start(refresh=True)
|
|
435
|
+
return
|
|
436
|
+
self._reasoning_summary_live.update(renderable, refresh=True)
|
|
437
|
+
|
|
438
|
+
def _stop_reasoning_summary_live(self) -> None:
|
|
439
|
+
live = self._reasoning_summary_live
|
|
440
|
+
self._reasoning_summary_live = None
|
|
441
|
+
if live is not None:
|
|
442
|
+
live.stop()
|
|
443
|
+
|
|
444
|
+
def _supports_live_thinking_spinner(self) -> bool:
|
|
445
|
+
if console_uses_ascii(self.console):
|
|
446
|
+
return False
|
|
447
|
+
return bool(getattr(self.console, "is_terminal", False))
|
|
448
|
+
|
|
449
|
+
def _make_thinking_renderable(self) -> Text:
|
|
450
|
+
if not self._spinner_active:
|
|
451
|
+
return Text("")
|
|
452
|
+
elapsed = max(int(time.monotonic() - self._thinking_start), 0)
|
|
453
|
+
spinner_char = _THINKING_SPINNER_FRAMES[self._spinner_frame % len(_THINKING_SPINNER_FRAMES)]
|
|
454
|
+
spinner_char = console_glyph(self.console, spinner_char, "*")
|
|
455
|
+
self._spinner_frame = (self._spinner_frame + 1) % len(_THINKING_SPINNER_FRAMES)
|
|
456
|
+
style = _STYLE_WARNING if elapsed >= 30 else _STYLE_META
|
|
457
|
+
return Text(f"{spinner_char} {self._spinner_label} {elapsed}s", style=style)
|
|
458
|
+
|
|
459
|
+
def _stop_thinking_spinner(self) -> None:
|
|
460
|
+
live = self._thinking_live
|
|
461
|
+
self._spinner_active = False
|
|
462
|
+
self._thinking_live = None
|
|
463
|
+
self._thinking_start = 0.0
|
|
464
|
+
self._spinner_frame = 0
|
|
465
|
+
self._spinner_label = "Thinking..."
|
|
466
|
+
if live is not None:
|
|
467
|
+
live.stop()
|
|
468
|
+
|
|
469
|
+
def _start_thinking_spinner(self, *, label: str) -> None:
|
|
470
|
+
if self._assistant_stream_open:
|
|
471
|
+
return
|
|
472
|
+
self._stop_thinking_spinner()
|
|
473
|
+
self._spinner_label = label
|
|
474
|
+
self._spinner_frame = 0
|
|
475
|
+
if not self._supports_live_thinking_spinner():
|
|
476
|
+
if self._trace_level == "off" and label == "Thinking...":
|
|
477
|
+
self._emit_trace_line(
|
|
478
|
+
label,
|
|
479
|
+
style=_STYLE_META,
|
|
480
|
+
prefix=console_glyph(self.console, "• ", "* "),
|
|
481
|
+
prefix_style=_STYLE_CHROME,
|
|
482
|
+
dedupe_key=f"thinking-fallback:{label}",
|
|
483
|
+
)
|
|
484
|
+
return
|
|
485
|
+
self._thinking_start = time.monotonic()
|
|
486
|
+
self._spinner_active = True
|
|
487
|
+
self._thinking_live = Live(
|
|
488
|
+
_ThinkingSpinnerRenderable(self),
|
|
489
|
+
console=self.console,
|
|
490
|
+
auto_refresh=True,
|
|
491
|
+
refresh_per_second=4,
|
|
492
|
+
transient=True,
|
|
493
|
+
)
|
|
494
|
+
self._thinking_live.start(refresh=True)
|
|
495
|
+
|
|
496
|
+
def _assistant_stream(self) -> object:
|
|
497
|
+
return self.console.file if self.console.file is not None else sys.stdout
|
|
498
|
+
|
|
499
|
+
def _count_terminal_lines(self, text: str) -> int:
|
|
500
|
+
width = max(int(getattr(self.console, "width", 0) or 0), 1)
|
|
501
|
+
total = 0
|
|
502
|
+
for logical_line in str(text).split("\n"):
|
|
503
|
+
if not logical_line:
|
|
504
|
+
total += 1
|
|
505
|
+
continue
|
|
506
|
+
total += max(1, math.ceil(len(logical_line) / width))
|
|
507
|
+
return total
|
|
508
|
+
|
|
509
|
+
def _should_skip_stream_markdown_rerender(self, text: str) -> bool:
|
|
510
|
+
return not _looks_like_markdown(text)
|
|
511
|
+
|
|
512
|
+
def _render_markdown(self, text: str) -> None:
|
|
513
|
+
self.console.print(Markdown(text, code_theme="monokai"))
|
|
514
|
+
|
|
515
|
+
def _erase_streamed_output_lines(self, line_count: int) -> None:
|
|
516
|
+
if line_count <= 0:
|
|
517
|
+
return
|
|
518
|
+
stream = self._assistant_stream()
|
|
519
|
+
safe_write(stream, "\r\033[2K")
|
|
520
|
+
for _ in range(max(0, line_count - 1)):
|
|
521
|
+
safe_write(stream, "\033[A\033[2K")
|
|
522
|
+
safe_write(stream, "\r")
|
|
523
|
+
|
|
524
|
+
def _subagent_indent(self, *, nesting_depth: int = 1) -> str:
|
|
525
|
+
indent = " " * max(nesting_depth, 1)
|
|
526
|
+
return indent
|
|
527
|
+
|
|
528
|
+
def _emit_subagent_border_top(
|
|
529
|
+
self,
|
|
530
|
+
*,
|
|
531
|
+
subagent_name: str,
|
|
532
|
+
subagent_mode: str,
|
|
533
|
+
nesting_depth: int = 1,
|
|
534
|
+
) -> None:
|
|
535
|
+
indent = self._subagent_indent(nesting_depth=nesting_depth)
|
|
536
|
+
self._emit_trace_line(
|
|
537
|
+
f"{subagent_name} · {subagent_mode}",
|
|
538
|
+
style=f"bold {_STYLE_SUBAGENT}",
|
|
539
|
+
prefix=_subagent_top_prefix(self.console, indent=indent),
|
|
540
|
+
prefix_style=_STYLE_SUBAGENT,
|
|
541
|
+
dedupe_key=f"subagent-top:{subagent_name}:{subagent_mode}:{nesting_depth}",
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def _emit_subagent_border_bottom(
|
|
545
|
+
self,
|
|
546
|
+
*,
|
|
547
|
+
subagent_name: str,
|
|
548
|
+
status_label: str,
|
|
549
|
+
status_style: str,
|
|
550
|
+
steps_completed: int,
|
|
551
|
+
elapsed_ms: int,
|
|
552
|
+
nesting_depth: int = 1,
|
|
553
|
+
) -> None:
|
|
554
|
+
self._stop_thinking_spinner()
|
|
555
|
+
indent = self._subagent_indent(nesting_depth=nesting_depth)
|
|
556
|
+
step_text = _format_step_count(steps_completed)
|
|
557
|
+
elapsed = _format_duration_ms(elapsed_ms)
|
|
558
|
+
self._thinking_open = True
|
|
559
|
+
self._last_trace_line_key = f"subagent-bottom:{subagent_name}:{status_label}:{steps_completed}:{elapsed_ms}:{nesting_depth}"
|
|
560
|
+
self.console.print(
|
|
561
|
+
Text.assemble(
|
|
562
|
+
(_subagent_bottom_prefix(self.console, indent=indent), _STYLE_SUBAGENT),
|
|
563
|
+
(status_label, status_style),
|
|
564
|
+
(_separator(self.console), _STYLE_SUBAGENT),
|
|
565
|
+
(step_text, _STYLE_SUBAGENT),
|
|
566
|
+
(_separator(self.console), _STYLE_SUBAGENT),
|
|
567
|
+
(elapsed, _STYLE_SUBAGENT),
|
|
568
|
+
),
|
|
569
|
+
highlight=False,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def _emit_subagent_trace(
|
|
573
|
+
self,
|
|
574
|
+
*,
|
|
575
|
+
subagent_name: str,
|
|
576
|
+
message: str,
|
|
577
|
+
style: str,
|
|
578
|
+
nesting_depth: int = 1,
|
|
579
|
+
) -> None:
|
|
580
|
+
indent = self._subagent_indent(nesting_depth=nesting_depth)
|
|
581
|
+
self._emit_trace_line(
|
|
582
|
+
message,
|
|
583
|
+
style=style,
|
|
584
|
+
prefix=_subagent_bar_prefix(self.console, indent=indent),
|
|
585
|
+
prefix_style=_STYLE_SUBAGENT,
|
|
586
|
+
dedupe_key=f"subagent:{subagent_name}:{nesting_depth}:{message}",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
def _print_left_bar_block(
|
|
590
|
+
self,
|
|
591
|
+
lines: list[str],
|
|
592
|
+
*,
|
|
593
|
+
bar_style: str,
|
|
594
|
+
text_style: str,
|
|
595
|
+
) -> None:
|
|
596
|
+
self._stop_thinking_spinner()
|
|
597
|
+
for line in lines:
|
|
598
|
+
self.console.print(
|
|
599
|
+
_prefixed_text(
|
|
600
|
+
prefix=_bar_prefix(self.console),
|
|
601
|
+
prefix_style=bar_style,
|
|
602
|
+
text=_surface_text(self.console, line),
|
|
603
|
+
text_style=text_style,
|
|
604
|
+
),
|
|
605
|
+
highlight=False,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def _open_answer_section(self) -> None:
|
|
609
|
+
self._stop_thinking_spinner()
|
|
610
|
+
if self._thinking_open:
|
|
611
|
+
self.console.print("")
|
|
612
|
+
self._reset_thinking()
|
|
613
|
+
elapsed = self._consume_turn_elapsed_label()
|
|
614
|
+
if elapsed is None:
|
|
615
|
+
self.console.rule(style=_STYLE_CHROME)
|
|
616
|
+
return
|
|
617
|
+
self.console.rule(Text(elapsed, style=_STYLE_META), style=_STYLE_CHROME, align="right")
|
|
618
|
+
|
|
619
|
+
def _consume_turn_elapsed_label(self) -> str | None:
|
|
620
|
+
start = self._turn_start_time
|
|
621
|
+
self._turn_start_time = None
|
|
622
|
+
if start is None:
|
|
623
|
+
return None
|
|
624
|
+
return _format_turn_elapsed(time.monotonic() - start)
|
|
625
|
+
|
|
626
|
+
def on_status_update(self, status: StatusEvent) -> None:
|
|
627
|
+
if not self._show_status_line:
|
|
628
|
+
return
|
|
629
|
+
cwd = Path(status.workspace).name or status.workspace
|
|
630
|
+
parts = [cwd, status.model, status.mode]
|
|
631
|
+
if status.task and status.task != "-":
|
|
632
|
+
parts.append(status.task)
|
|
633
|
+
if status.dirty:
|
|
634
|
+
parts.append("dirty")
|
|
635
|
+
self.console.print(" · ".join(parts), style=_STYLE_META, highlight=False)
|
|
636
|
+
|
|
637
|
+
def on_user_message(self, text: str) -> None:
|
|
638
|
+
self._reset_thinking()
|
|
639
|
+
self._turn_started = True
|
|
640
|
+
self._working_banner_shown = False
|
|
641
|
+
self._turn_start_time = time.monotonic()
|
|
642
|
+
approved_plan_message = extract_approved_plan_user_message(text)
|
|
643
|
+
if approved_plan_message is not None:
|
|
644
|
+
self._print_left_bar_block(
|
|
645
|
+
["Plan approved. Executing..."],
|
|
646
|
+
bar_style=_STYLE_META,
|
|
647
|
+
text_style=_STYLE_META,
|
|
648
|
+
)
|
|
649
|
+
display_text = approved_plan_message or text
|
|
650
|
+
clean = _redact(display_text.strip())
|
|
651
|
+
self._print_left_bar_block(
|
|
652
|
+
_split_lines(clean or "(empty message)"),
|
|
653
|
+
bar_style=_STYLE_EMPHASIS,
|
|
654
|
+
text_style=_STYLE_EMPHASIS,
|
|
655
|
+
)
|
|
656
|
+
self._start_thinking_spinner(label="Thinking...")
|
|
657
|
+
|
|
658
|
+
def on_assistant_token(self, delta: str) -> None:
|
|
659
|
+
self._turn_started = False
|
|
660
|
+
self._working_banner_shown = False
|
|
661
|
+
self._flush_reasoning_summary()
|
|
662
|
+
self._stop_thinking_spinner()
|
|
663
|
+
if not self._assistant_stream_open:
|
|
664
|
+
self._open_answer_section()
|
|
665
|
+
self._assistant_stream_open = True
|
|
666
|
+
clean_delta = _redact(delta)
|
|
667
|
+
stream = self._assistant_stream()
|
|
668
|
+
safe_write(stream, clean_delta)
|
|
669
|
+
self._stream_buffer.append(clean_delta)
|
|
670
|
+
|
|
671
|
+
def on_reasoning_start(self, block_id: str) -> None:
|
|
672
|
+
"""Open a provider-call-scoped safe-summary block."""
|
|
673
|
+
|
|
674
|
+
if self._trace_level == "off":
|
|
675
|
+
return
|
|
676
|
+
normalized = str(block_id or "").strip()
|
|
677
|
+
if not normalized or normalized == self._reasoning_summary_block_id:
|
|
678
|
+
return
|
|
679
|
+
if self._reasoning_summary_buffer:
|
|
680
|
+
self._flush_reasoning_summary()
|
|
681
|
+
self._reasoning_summary_block_id = normalized
|
|
682
|
+
|
|
683
|
+
def on_reasoning_token(self, delta: str) -> None:
|
|
684
|
+
"""Buffer a safe provider-generated reasoning summary for display."""
|
|
685
|
+
|
|
686
|
+
if self._trace_level == "off" or not delta:
|
|
687
|
+
return
|
|
688
|
+
if self._reasoning_summary_block_id is None:
|
|
689
|
+
self._reasoning_summary_block_id = "legacy"
|
|
690
|
+
self._reasoning_summary_buffer.append(delta)
|
|
691
|
+
# Streaming clients call this incrementally; buffered clients call it
|
|
692
|
+
# only after the provider response completes, so live rendering follows
|
|
693
|
+
# `/stream` timing without a second surface-level mode switch.
|
|
694
|
+
self._update_reasoning_summary_live()
|
|
695
|
+
|
|
696
|
+
def on_reasoning_end(self, block_id: str) -> None:
|
|
697
|
+
normalized = str(block_id or "").strip()
|
|
698
|
+
if normalized and normalized != self._reasoning_summary_block_id:
|
|
699
|
+
return
|
|
700
|
+
self._flush_reasoning_summary()
|
|
701
|
+
|
|
702
|
+
def on_assistant_message_done(self, text: str) -> None:
|
|
703
|
+
self._turn_started = False
|
|
704
|
+
self._working_banner_shown = False
|
|
705
|
+
self._flush_reasoning_summary()
|
|
706
|
+
self._stop_thinking_spinner()
|
|
707
|
+
clean = _redact(text.strip())
|
|
708
|
+
if self._assistant_stream_open:
|
|
709
|
+
full_text = "".join(self._stream_buffer)
|
|
710
|
+
should_rerender = (
|
|
711
|
+
bool(full_text.strip())
|
|
712
|
+
and not self._should_skip_stream_markdown_rerender(full_text)
|
|
713
|
+
and bool(getattr(self.console, "is_terminal", False))
|
|
714
|
+
)
|
|
715
|
+
if should_rerender:
|
|
716
|
+
self._erase_streamed_output_lines(self._count_terminal_lines(full_text))
|
|
717
|
+
self._render_markdown(full_text)
|
|
718
|
+
else:
|
|
719
|
+
self.console.print("")
|
|
720
|
+
self._stream_buffer.clear()
|
|
721
|
+
self._assistant_stream_open = False
|
|
722
|
+
return
|
|
723
|
+
if not clean:
|
|
724
|
+
self._stream_buffer.clear()
|
|
725
|
+
return
|
|
726
|
+
self._open_answer_section()
|
|
727
|
+
self._render_markdown(clean)
|
|
728
|
+
self._stream_buffer.clear()
|
|
729
|
+
|
|
730
|
+
def on_progress_update(self, message: str) -> None:
|
|
731
|
+
if self._trace_level == "off":
|
|
732
|
+
return
|
|
733
|
+
self._emit_thinking(message, style="dim")
|
|
734
|
+
if _progress_message_completes_activity(message):
|
|
735
|
+
self._stop_thinking_spinner()
|
|
736
|
+
return
|
|
737
|
+
if not self._assistant_stream_open and not self._tool_start_info:
|
|
738
|
+
self._start_thinking_spinner(label="Thinking...")
|
|
739
|
+
|
|
740
|
+
def on_subagent_start(self, event: SubagentStartEvent) -> None:
|
|
741
|
+
if self._trace_level == "off":
|
|
742
|
+
return
|
|
743
|
+
self._emit_subagent_border_top(
|
|
744
|
+
subagent_name=subagent_identity(event.name, event.label),
|
|
745
|
+
subagent_mode=event.mode,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
def on_subagent_end(self, event: SubagentEndEvent) -> None:
|
|
749
|
+
if self._trace_level == "off":
|
|
750
|
+
return
|
|
751
|
+
status_label = "finished" if event.status == "success" else event.status
|
|
752
|
+
display_name = subagent_identity(event.name, event.label)
|
|
753
|
+
if event.error:
|
|
754
|
+
self._emit_subagent_trace(
|
|
755
|
+
subagent_name=display_name,
|
|
756
|
+
message=f"Error: {_truncate_inline(_redact(event.error), max_chars=140)}",
|
|
757
|
+
style=_STYLE_FAILURE,
|
|
758
|
+
)
|
|
759
|
+
status_style = _STYLE_SUCCESS if event.status == "success" else _STYLE_FAILURE
|
|
760
|
+
self._emit_subagent_border_bottom(
|
|
761
|
+
subagent_name=display_name,
|
|
762
|
+
status_label=status_label,
|
|
763
|
+
status_style=status_style,
|
|
764
|
+
steps_completed=event.steps_completed,
|
|
765
|
+
elapsed_ms=event.elapsed_ms,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
def on_tool_start(self, event: ToolStartEvent) -> None:
|
|
769
|
+
self._flush_reasoning_summary()
|
|
770
|
+
self._stop_thinking_spinner()
|
|
771
|
+
if self._turn_started and not self._working_banner_shown:
|
|
772
|
+
elapsed = self._consume_turn_elapsed_label()
|
|
773
|
+
if elapsed is None:
|
|
774
|
+
self.console.print("Working... Press Esc to interrupt.", style=_STYLE_META)
|
|
775
|
+
else:
|
|
776
|
+
self.console.print(
|
|
777
|
+
Text.assemble(
|
|
778
|
+
("Working... Press Esc to interrupt.", _STYLE_META),
|
|
779
|
+
(" ", _STYLE_META),
|
|
780
|
+
(elapsed, _STYLE_META),
|
|
781
|
+
),
|
|
782
|
+
highlight=False,
|
|
783
|
+
)
|
|
784
|
+
self._working_banner_shown = True
|
|
785
|
+
display = _tool_display_name(event.name)
|
|
786
|
+
self._tool_start_info[event.tool_call_id] = (event.step, display)
|
|
787
|
+
if self._trace_level == "off":
|
|
788
|
+
return
|
|
789
|
+
if event.subagent_name:
|
|
790
|
+
self._emit_subagent_trace(
|
|
791
|
+
subagent_name=event.subagent_name,
|
|
792
|
+
message=f"Step {event.step}: {display}",
|
|
793
|
+
style=_STYLE_CHROME,
|
|
794
|
+
nesting_depth=event.nesting_depth,
|
|
795
|
+
)
|
|
796
|
+
else:
|
|
797
|
+
self._emit_thinking(f"Step {event.step}: {display}", style=_STYLE_CHROME)
|
|
798
|
+
if self._trace_level != "full":
|
|
799
|
+
return
|
|
800
|
+
if event.subagent_name:
|
|
801
|
+
self._emit_subagent_trace(
|
|
802
|
+
subagent_name=event.subagent_name,
|
|
803
|
+
message=f"Input: {_tool_input_preview(event.name, event.args)}",
|
|
804
|
+
style=_STYLE_META,
|
|
805
|
+
nesting_depth=event.nesting_depth,
|
|
806
|
+
)
|
|
807
|
+
else:
|
|
808
|
+
self._emit_thinking(
|
|
809
|
+
f"Input: {_tool_input_preview(event.name, event.args)}",
|
|
810
|
+
style=_STYLE_META,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
def on_tool_output(self, event: ToolOutputEvent) -> None:
|
|
814
|
+
summary = _summarize_tool_output(event.name, event.chunk)
|
|
815
|
+
if summary.strip():
|
|
816
|
+
self._tool_output_summary[event.tool_call_id] = summary
|
|
817
|
+
|
|
818
|
+
def on_tool_end(self, event: ToolEndEvent) -> None:
|
|
819
|
+
display = _tool_display_name(event.name)
|
|
820
|
+
elapsed = _format_duration_ms(event.elapsed_ms)
|
|
821
|
+
detail = ""
|
|
822
|
+
err = event.meta.get("error")
|
|
823
|
+
if err:
|
|
824
|
+
err_preview = _truncate_inline(_redact(str(err)), max_chars=140)
|
|
825
|
+
detail = f": {err_preview}"
|
|
826
|
+
self._tool_start_info.pop(event.tool_call_id, None)
|
|
827
|
+
summary = self._tool_output_summary.pop(event.tool_call_id, "").strip()
|
|
828
|
+
should_render_outcome = (
|
|
829
|
+
self._trace_level != "off"
|
|
830
|
+
or event.status != "done"
|
|
831
|
+
or bool(event.meta.get("approval_declined"))
|
|
832
|
+
)
|
|
833
|
+
if should_render_outcome:
|
|
834
|
+
if event.status == "done":
|
|
835
|
+
outcome = f"{display} ({elapsed})"
|
|
836
|
+
if summary:
|
|
837
|
+
outcome = f"{display}: {summary} ({elapsed})"
|
|
838
|
+
if event.subagent_name:
|
|
839
|
+
self._emit_subagent_trace(
|
|
840
|
+
subagent_name=event.subagent_name,
|
|
841
|
+
message=outcome,
|
|
842
|
+
style=_STYLE_CHROME,
|
|
843
|
+
nesting_depth=event.nesting_depth,
|
|
844
|
+
)
|
|
845
|
+
else:
|
|
846
|
+
self._emit_thinking(outcome, style=_STYLE_CHROME)
|
|
847
|
+
elif event.meta.get("approval_declined"):
|
|
848
|
+
message = f"{display} approval declined ({elapsed}){detail}"
|
|
849
|
+
if event.subagent_name:
|
|
850
|
+
self._emit_subagent_trace(
|
|
851
|
+
subagent_name=event.subagent_name,
|
|
852
|
+
message=message,
|
|
853
|
+
style="red",
|
|
854
|
+
nesting_depth=event.nesting_depth,
|
|
855
|
+
)
|
|
856
|
+
else:
|
|
857
|
+
self._emit_thinking(message, style="red")
|
|
858
|
+
elif event.subagent_name:
|
|
859
|
+
self._emit_subagent_trace(
|
|
860
|
+
subagent_name=event.subagent_name,
|
|
861
|
+
message=f"{display} failed ({elapsed}){detail}",
|
|
862
|
+
style="red",
|
|
863
|
+
nesting_depth=event.nesting_depth,
|
|
864
|
+
)
|
|
865
|
+
else:
|
|
866
|
+
self._emit_thinking(f"{display} failed ({elapsed}){detail}", style="red")
|
|
867
|
+
if not self._assistant_stream_open and not self._tool_start_info:
|
|
868
|
+
self._start_thinking_spinner(label="Reasoning...")
|
|
869
|
+
|
|
870
|
+
def on_patch_generated(self, event: PatchEvent) -> None:
|
|
871
|
+
self._flush_reasoning_summary()
|
|
872
|
+
self._stop_thinking_spinner()
|
|
873
|
+
self.console.print(
|
|
874
|
+
_prefixed_text(
|
|
875
|
+
prefix=_bar_prefix(self.console),
|
|
876
|
+
prefix_style=_STYLE_CHROME,
|
|
877
|
+
text=", ".join(event.files[:5]) or "Patch Preview",
|
|
878
|
+
text_style=_STYLE_EMPHASIS,
|
|
879
|
+
),
|
|
880
|
+
highlight=False,
|
|
881
|
+
)
|
|
882
|
+
if event.summary:
|
|
883
|
+
self.console.print(
|
|
884
|
+
_prefixed_text(
|
|
885
|
+
prefix=_bar_prefix(self.console),
|
|
886
|
+
prefix_style=_STYLE_CHROME,
|
|
887
|
+
text=event.summary,
|
|
888
|
+
text_style=_STYLE_META,
|
|
889
|
+
),
|
|
890
|
+
highlight=False,
|
|
891
|
+
)
|
|
892
|
+
preview = _redact(event.diff)
|
|
893
|
+
if len(preview) > 1200:
|
|
894
|
+
preview = preview[:1200] + "\n...(truncated)"
|
|
895
|
+
lines = _split_lines(preview or "(empty diff)")
|
|
896
|
+
for line in lines:
|
|
897
|
+
style = _STYLE_META
|
|
898
|
+
if line.startswith(("diff --git", "--- ", "+++ ", "@@")):
|
|
899
|
+
style = _STYLE_EMPHASIS
|
|
900
|
+
elif line.startswith("+"):
|
|
901
|
+
style = _STYLE_EMPHASIS
|
|
902
|
+
elif line.startswith("-"):
|
|
903
|
+
style = _STYLE_CHROME
|
|
904
|
+
self.console.print(
|
|
905
|
+
_prefixed_text(
|
|
906
|
+
prefix=_bar_prefix(self.console),
|
|
907
|
+
prefix_style=_STYLE_CHROME,
|
|
908
|
+
text=line,
|
|
909
|
+
text_style=style,
|
|
910
|
+
),
|
|
911
|
+
highlight=False,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
def on_error(self, err: str) -> None:
|
|
915
|
+
self._flush_reasoning_summary()
|
|
916
|
+
self._stop_thinking_spinner()
|
|
917
|
+
if self._assistant_stream_open:
|
|
918
|
+
self.console.print("")
|
|
919
|
+
self._assistant_stream_open = False
|
|
920
|
+
self._turn_started = False
|
|
921
|
+
self._working_banner_shown = False
|
|
922
|
+
self._reset_thinking()
|
|
923
|
+
try:
|
|
924
|
+
self.console.print(_error_renderable(err))
|
|
925
|
+
except Exception as exc: # noqa: BLE001 - final output fallback must not double-crash.
|
|
926
|
+
safe_plain_error(
|
|
927
|
+
stream=getattr(self.console, "file", None),
|
|
928
|
+
error_type=type(exc).__name__,
|
|
929
|
+
message=err,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
def on_warning(self, warning: str) -> None:
|
|
933
|
+
self._flush_reasoning_summary()
|
|
934
|
+
self._stop_thinking_spinner()
|
|
935
|
+
if self._assistant_stream_open:
|
|
936
|
+
self.console.print("")
|
|
937
|
+
self._assistant_stream_open = False
|
|
938
|
+
try:
|
|
939
|
+
self.console.print(_warning_renderable(warning))
|
|
940
|
+
except Exception:
|
|
941
|
+
safe_plain_error(
|
|
942
|
+
stream=getattr(self.console, "file", None),
|
|
943
|
+
error_type="Warning",
|
|
944
|
+
message=warning,
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
def _emit_event_info(self, message: str) -> None:
|
|
948
|
+
if self._trace_level == "off":
|
|
949
|
+
return
|
|
950
|
+
self._emit_thinking(message, style=_STYLE_META)
|
|
951
|
+
|
|
952
|
+
def _event_scope_prefix(
|
|
953
|
+
self,
|
|
954
|
+
*,
|
|
955
|
+
worker_id: str | None = None,
|
|
956
|
+
role: str | None = None,
|
|
957
|
+
) -> str:
|
|
958
|
+
parts = [part for part in (worker_id, role) if part]
|
|
959
|
+
if not parts:
|
|
960
|
+
return ""
|
|
961
|
+
return "[" + " · ".join(parts) + "] "
|
|
962
|
+
|
|
963
|
+
# RichSurface renders these through legacy on_* methods; emit_* are no-ops here so
|
|
964
|
+
# dual emission from agent_loop does not double-render in the Rich CLI.
|
|
965
|
+
def emit_message_delta(
|
|
966
|
+
self,
|
|
967
|
+
text: str,
|
|
968
|
+
*,
|
|
969
|
+
worker_id: str | None = None,
|
|
970
|
+
role: str | None = None,
|
|
971
|
+
) -> None:
|
|
972
|
+
_ = (text, worker_id, role)
|
|
973
|
+
|
|
974
|
+
def emit_message_end(
|
|
975
|
+
self,
|
|
976
|
+
text: str = "",
|
|
977
|
+
*,
|
|
978
|
+
worker_id: str | None = None,
|
|
979
|
+
role: str | None = None,
|
|
980
|
+
) -> None:
|
|
981
|
+
_ = (text, worker_id, role)
|
|
982
|
+
|
|
983
|
+
def emit_tool_call_started(
|
|
984
|
+
self,
|
|
985
|
+
call_id: str,
|
|
986
|
+
name: str,
|
|
987
|
+
arguments_preview: str,
|
|
988
|
+
*,
|
|
989
|
+
worker_id: str | None = None,
|
|
990
|
+
role: str | None = None,
|
|
991
|
+
) -> None:
|
|
992
|
+
_ = (call_id, name, arguments_preview, worker_id, role)
|
|
993
|
+
|
|
994
|
+
def emit_tool_call_progress(
|
|
995
|
+
self,
|
|
996
|
+
call_id: str,
|
|
997
|
+
text: str,
|
|
998
|
+
*,
|
|
999
|
+
worker_id: str | None = None,
|
|
1000
|
+
role: str | None = None,
|
|
1001
|
+
) -> None:
|
|
1002
|
+
_ = (call_id, text, worker_id, role)
|
|
1003
|
+
|
|
1004
|
+
def emit_tool_call_completed(
|
|
1005
|
+
self,
|
|
1006
|
+
call_id: str,
|
|
1007
|
+
success: bool,
|
|
1008
|
+
result_preview: str,
|
|
1009
|
+
*,
|
|
1010
|
+
worker_id: str | None = None,
|
|
1011
|
+
role: str | None = None,
|
|
1012
|
+
) -> None:
|
|
1013
|
+
_ = (call_id, success, result_preview, worker_id, role)
|
|
1014
|
+
|
|
1015
|
+
def emit_status_update(
|
|
1016
|
+
self,
|
|
1017
|
+
*,
|
|
1018
|
+
tokens_in: int | None = None,
|
|
1019
|
+
tokens_out: int | None = None,
|
|
1020
|
+
cached_tokens: int | None = None,
|
|
1021
|
+
cost_usd: float | None = None,
|
|
1022
|
+
mode: str | None = None,
|
|
1023
|
+
model: str | None = None,
|
|
1024
|
+
step: int | None = None,
|
|
1025
|
+
step_budget: int | None = None,
|
|
1026
|
+
) -> None:
|
|
1027
|
+
# TODO: dedicated rendering for status_update.
|
|
1028
|
+
parts: list[str] = []
|
|
1029
|
+
if model:
|
|
1030
|
+
parts.append(f"model={model}")
|
|
1031
|
+
if mode:
|
|
1032
|
+
parts.append(f"mode={mode}")
|
|
1033
|
+
if step is not None:
|
|
1034
|
+
step_text = f"step={step}"
|
|
1035
|
+
if step_budget is not None:
|
|
1036
|
+
step_text += f"/{step_budget}"
|
|
1037
|
+
parts.append(step_text)
|
|
1038
|
+
if tokens_in is not None:
|
|
1039
|
+
parts.append(f"in={tokens_in}")
|
|
1040
|
+
if tokens_out is not None:
|
|
1041
|
+
parts.append(f"out={tokens_out}")
|
|
1042
|
+
if cached_tokens is not None:
|
|
1043
|
+
parts.append(f"cached={cached_tokens}")
|
|
1044
|
+
if cost_usd is not None:
|
|
1045
|
+
parts.append(f"cost=${cost_usd:.6f}")
|
|
1046
|
+
self._emit_event_info("Status: " + (" · ".join(parts) if parts else "updated"))
|
|
1047
|
+
|
|
1048
|
+
def emit_mode_changed(self, mode: str) -> None:
|
|
1049
|
+
# TODO: dedicated rendering for mode_changed.
|
|
1050
|
+
self._emit_event_info(f"Mode: {mode}")
|
|
1051
|
+
|
|
1052
|
+
def reset_streamed_assistant(self) -> None:
|
|
1053
|
+
# Classic terminal output cannot be unprinted; mark the restart so the
|
|
1054
|
+
# restreamed reply that follows is readable as a retry, not an echo.
|
|
1055
|
+
self._emit_event_info("Provider retry — restarting the reply…")
|
|
1056
|
+
|
|
1057
|
+
def emit_persona_changed(self, persona: str, effective_mode: str, source: str = "user") -> None:
|
|
1058
|
+
self._emit_event_info(f"Persona: {persona} ({effective_mode}, {source})")
|
|
1059
|
+
|
|
1060
|
+
def emit_plan_node_updated(
|
|
1061
|
+
self,
|
|
1062
|
+
node_id: str,
|
|
1063
|
+
state: str,
|
|
1064
|
+
summary: str | None = None,
|
|
1065
|
+
*,
|
|
1066
|
+
worker_id: str | None = None,
|
|
1067
|
+
role: str | None = None,
|
|
1068
|
+
) -> None:
|
|
1069
|
+
# TODO: dedicated rendering for plan_node_updated.
|
|
1070
|
+
prefix = self._event_scope_prefix(worker_id=worker_id, role=role)
|
|
1071
|
+
detail = f" · {summary}" if summary else ""
|
|
1072
|
+
self._emit_event_info(f"{prefix}Plan {node_id}: {state}{detail}")
|
|
1073
|
+
|
|
1074
|
+
def emit_swarm_worker_state_changed(
|
|
1075
|
+
self,
|
|
1076
|
+
worker_id: str,
|
|
1077
|
+
state: str,
|
|
1078
|
+
*,
|
|
1079
|
+
role: str | None = None,
|
|
1080
|
+
) -> None:
|
|
1081
|
+
# TODO: dedicated rendering for swarm_worker_state_changed.
|
|
1082
|
+
suffix = f" · {role}" if role else ""
|
|
1083
|
+
self._emit_event_info(f"Worker {worker_id}: {state}{suffix}")
|
|
1084
|
+
|
|
1085
|
+
def emit_verify_gate_result(
|
|
1086
|
+
self,
|
|
1087
|
+
command: str,
|
|
1088
|
+
success: bool,
|
|
1089
|
+
summary: str,
|
|
1090
|
+
*,
|
|
1091
|
+
worker_id: str | None = None,
|
|
1092
|
+
role: str | None = None,
|
|
1093
|
+
) -> None:
|
|
1094
|
+
# TODO: dedicated rendering for verify_gate_result.
|
|
1095
|
+
prefix = self._event_scope_prefix(worker_id=worker_id, role=role)
|
|
1096
|
+
state = "passed" if success else "failed"
|
|
1097
|
+
self._emit_event_info(f"{prefix}Verify {state}: {command} · {summary}")
|
|
1098
|
+
|
|
1099
|
+
def emit_review_gate_decision(
|
|
1100
|
+
self,
|
|
1101
|
+
decision: str,
|
|
1102
|
+
summary: str,
|
|
1103
|
+
*,
|
|
1104
|
+
worker_id: str | None = None,
|
|
1105
|
+
role: str | None = None,
|
|
1106
|
+
) -> None:
|
|
1107
|
+
# TODO: dedicated rendering for review_gate_decision.
|
|
1108
|
+
prefix = self._event_scope_prefix(worker_id=worker_id, role=role)
|
|
1109
|
+
self._emit_event_info(f"{prefix}Review {decision}: {summary}")
|
|
1110
|
+
|
|
1111
|
+
def emit_error(
|
|
1112
|
+
self,
|
|
1113
|
+
code: str,
|
|
1114
|
+
message: str,
|
|
1115
|
+
recoverable: bool,
|
|
1116
|
+
*,
|
|
1117
|
+
worker_id: str | None = None,
|
|
1118
|
+
role: str | None = None,
|
|
1119
|
+
) -> None:
|
|
1120
|
+
prefix = self._event_scope_prefix(worker_id=worker_id, role=role)
|
|
1121
|
+
code_text = f"{code}: " if code else ""
|
|
1122
|
+
recovery_text = "" if recoverable else " (not recoverable)"
|
|
1123
|
+
self.on_error(f"{prefix}{code_text}{message}{recovery_text}")
|
|
1124
|
+
|
|
1125
|
+
def emit_warning(
|
|
1126
|
+
self,
|
|
1127
|
+
message: str,
|
|
1128
|
+
*,
|
|
1129
|
+
worker_id: str | None = None,
|
|
1130
|
+
role: str | None = None,
|
|
1131
|
+
) -> None:
|
|
1132
|
+
prefix = self._event_scope_prefix(worker_id=worker_id, role=role)
|
|
1133
|
+
self.on_warning(f"{prefix}{message}")
|
|
1134
|
+
|
|
1135
|
+
def emit_info(
|
|
1136
|
+
self,
|
|
1137
|
+
message: str,
|
|
1138
|
+
*,
|
|
1139
|
+
worker_id: str | None = None,
|
|
1140
|
+
role: str | None = None,
|
|
1141
|
+
) -> None:
|
|
1142
|
+
prefix = self._event_scope_prefix(worker_id=worker_id, role=role)
|
|
1143
|
+
self.on_progress_update(f"{prefix}{message}")
|
|
1144
|
+
|
|
1145
|
+
def emit_prompt_for_input(self, prompt_id: str, prompt_text: str, kind: str) -> None:
|
|
1146
|
+
# TODO: dedicated rendering for prompt_for_input.
|
|
1147
|
+
self._emit_event_info(f"Prompt {prompt_id} ({kind}): {prompt_text}")
|
|
1148
|
+
|
|
1149
|
+
def emit_config_form_request(self, form_id: str, schema: dict[str, Any]) -> None:
|
|
1150
|
+
# TODO: dedicated rendering for config_form_request.
|
|
1151
|
+
title = str(schema.get("title") or schema.get("name") or "configuration")
|
|
1152
|
+
self._emit_event_info(f"Config form {form_id}: {title}")
|
|
1153
|
+
|
|
1154
|
+
def emit(self, event: Event) -> None:
|
|
1155
|
+
if event.type == MessageDelta.type:
|
|
1156
|
+
self.emit_message_delta(event.text, worker_id=event.worker_id, role=event.role)
|
|
1157
|
+
elif event.type == MessageEnd.type:
|
|
1158
|
+
self.emit_message_end(event.text, worker_id=event.worker_id, role=event.role)
|
|
1159
|
+
elif event.type == ToolCallStarted.type:
|
|
1160
|
+
self.emit_tool_call_started(
|
|
1161
|
+
event.call_id,
|
|
1162
|
+
event.name,
|
|
1163
|
+
event.arguments_preview,
|
|
1164
|
+
worker_id=event.worker_id,
|
|
1165
|
+
role=event.role,
|
|
1166
|
+
)
|
|
1167
|
+
elif event.type == ToolCallProgress.type:
|
|
1168
|
+
self.emit_tool_call_progress(
|
|
1169
|
+
event.call_id,
|
|
1170
|
+
event.text,
|
|
1171
|
+
worker_id=event.worker_id,
|
|
1172
|
+
role=event.role,
|
|
1173
|
+
)
|
|
1174
|
+
elif event.type == ToolCallCompleted.type:
|
|
1175
|
+
self.emit_tool_call_completed(
|
|
1176
|
+
event.call_id,
|
|
1177
|
+
event.success,
|
|
1178
|
+
event.result_preview,
|
|
1179
|
+
worker_id=event.worker_id,
|
|
1180
|
+
role=event.role,
|
|
1181
|
+
)
|
|
1182
|
+
elif event.type == StatusUpdate.type:
|
|
1183
|
+
self.emit_status_update(
|
|
1184
|
+
tokens_in=event.tokens_in,
|
|
1185
|
+
tokens_out=event.tokens_out,
|
|
1186
|
+
cached_tokens=event.cached_tokens,
|
|
1187
|
+
cost_usd=event.cost_usd,
|
|
1188
|
+
mode=event.mode,
|
|
1189
|
+
model=event.model,
|
|
1190
|
+
step=event.step,
|
|
1191
|
+
step_budget=event.step_budget,
|
|
1192
|
+
)
|
|
1193
|
+
elif event.type == ModeChanged.type:
|
|
1194
|
+
self.emit_mode_changed(event.mode)
|
|
1195
|
+
elif event.type == PersonaChanged.type:
|
|
1196
|
+
self.emit_persona_changed(event.persona, event.effective_mode, event.source)
|
|
1197
|
+
elif event.type == PlanNodeUpdated.type:
|
|
1198
|
+
self.emit_plan_node_updated(
|
|
1199
|
+
event.node_id,
|
|
1200
|
+
event.state,
|
|
1201
|
+
event.summary,
|
|
1202
|
+
worker_id=event.worker_id,
|
|
1203
|
+
role=event.role,
|
|
1204
|
+
)
|
|
1205
|
+
elif event.type == SwarmWorkerStateChanged.type:
|
|
1206
|
+
self.emit_swarm_worker_state_changed(event.worker_id, event.state, role=event.role)
|
|
1207
|
+
elif event.type == VerifyGateResult.type:
|
|
1208
|
+
self.emit_verify_gate_result(
|
|
1209
|
+
event.command,
|
|
1210
|
+
event.success,
|
|
1211
|
+
event.summary,
|
|
1212
|
+
worker_id=event.worker_id,
|
|
1213
|
+
role=event.role,
|
|
1214
|
+
)
|
|
1215
|
+
elif event.type == ReviewGateDecision.type:
|
|
1216
|
+
self.emit_review_gate_decision(
|
|
1217
|
+
event.decision,
|
|
1218
|
+
event.summary,
|
|
1219
|
+
worker_id=event.worker_id,
|
|
1220
|
+
role=event.role,
|
|
1221
|
+
)
|
|
1222
|
+
elif event.type == ErrorRaised.type:
|
|
1223
|
+
self.emit_error(
|
|
1224
|
+
event.code,
|
|
1225
|
+
event.message,
|
|
1226
|
+
event.recoverable,
|
|
1227
|
+
worker_id=event.worker_id,
|
|
1228
|
+
role=event.role,
|
|
1229
|
+
)
|
|
1230
|
+
elif event.type == WarningEmitted.type:
|
|
1231
|
+
self.emit_warning(event.message, worker_id=event.worker_id, role=event.role)
|
|
1232
|
+
elif event.type == InfoEmitted.type:
|
|
1233
|
+
self.emit_info(event.message, worker_id=event.worker_id, role=event.role)
|
|
1234
|
+
elif event.type == PromptForInput.type:
|
|
1235
|
+
self.emit_prompt_for_input(event.prompt_id, event.prompt_text, event.kind)
|
|
1236
|
+
elif event.type == ConfigFormRequest.type:
|
|
1237
|
+
self.emit_config_form_request(event.form_id, event.schema)
|
|
1238
|
+
|
|
1239
|
+
def _prompt_approval_choice_fallback(self) -> str:
|
|
1240
|
+
try:
|
|
1241
|
+
return (
|
|
1242
|
+
Prompt.ask(
|
|
1243
|
+
"Select option",
|
|
1244
|
+
console=self.console,
|
|
1245
|
+
default="n",
|
|
1246
|
+
show_default=False,
|
|
1247
|
+
show_choices=False,
|
|
1248
|
+
)
|
|
1249
|
+
.strip()
|
|
1250
|
+
.lower()
|
|
1251
|
+
)
|
|
1252
|
+
except (EOFError, KeyboardInterrupt):
|
|
1253
|
+
return "n"
|
|
1254
|
+
|
|
1255
|
+
def _uses_inline_approval_selector(self) -> bool:
|
|
1256
|
+
if not getattr(self.console, "is_terminal", False):
|
|
1257
|
+
return False
|
|
1258
|
+
if os.name != "posix":
|
|
1259
|
+
return False
|
|
1260
|
+
try:
|
|
1261
|
+
fd = os.open("/dev/tty", os.O_RDWR)
|
|
1262
|
+
except Exception:
|
|
1263
|
+
return False
|
|
1264
|
+
try:
|
|
1265
|
+
return True
|
|
1266
|
+
finally:
|
|
1267
|
+
os.close(fd)
|
|
1268
|
+
|
|
1269
|
+
def _approval_selector_options(self) -> list[tuple[str, str]]:
|
|
1270
|
+
return [
|
|
1271
|
+
("y", "Allow"),
|
|
1272
|
+
("a", "Always allow for session"),
|
|
1273
|
+
("n", "Deny"),
|
|
1274
|
+
("v", "View context"),
|
|
1275
|
+
]
|
|
1276
|
+
|
|
1277
|
+
def _approval_confirmation(self, choice: str) -> tuple[str, str] | None:
|
|
1278
|
+
mapping = {
|
|
1279
|
+
"y": (" Allowed", _STYLE_SUCCESS),
|
|
1280
|
+
"a": (" Always allowed", _STYLE_SUCCESS),
|
|
1281
|
+
"n": (" Denied", _STYLE_WARNING),
|
|
1282
|
+
}
|
|
1283
|
+
return mapping.get(choice)
|
|
1284
|
+
|
|
1285
|
+
def _render_approval_selector_menu(self, *, selected_index: int) -> None:
|
|
1286
|
+
options = self._approval_selector_options()
|
|
1287
|
+
for index, (_key, label) in enumerate(options):
|
|
1288
|
+
selected = index == selected_index
|
|
1289
|
+
prefix = " > " if selected else " "
|
|
1290
|
+
prefix_style = "bold green" if selected else _STYLE_META
|
|
1291
|
+
text_style = _STYLE_EMPHASIS if selected else _STYLE_META
|
|
1292
|
+
self.console.print(
|
|
1293
|
+
Text.assemble((prefix, prefix_style), (label, text_style)),
|
|
1294
|
+
highlight=False,
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
def _erase_approval_selector_menu(self) -> None:
|
|
1298
|
+
stream = self.console.file if self.console.file is not None else sys.stdout
|
|
1299
|
+
for _ in range(len(self._approval_selector_options())):
|
|
1300
|
+
safe_write(stream, "\033[A\033[2K")
|
|
1301
|
+
safe_write(stream, "\r")
|
|
1302
|
+
|
|
1303
|
+
def _read_approval_selector_keypress(self) -> str:
|
|
1304
|
+
import select
|
|
1305
|
+
import termios
|
|
1306
|
+
import tty
|
|
1307
|
+
|
|
1308
|
+
fd = os.open("/dev/tty", os.O_RDWR)
|
|
1309
|
+
old_settings = termios.tcgetattr(fd)
|
|
1310
|
+
try:
|
|
1311
|
+
tty.setraw(fd)
|
|
1312
|
+
first = os.read(fd, 1)
|
|
1313
|
+
if not first:
|
|
1314
|
+
raise EOFError
|
|
1315
|
+
ch = first.decode("utf-8", errors="ignore")
|
|
1316
|
+
if ch == "\x1b":
|
|
1317
|
+
if select.select([fd], [], [], 0.05)[0]:
|
|
1318
|
+
second = os.read(fd, 1)
|
|
1319
|
+
if not second:
|
|
1320
|
+
return "\x1b"
|
|
1321
|
+
ch2 = second.decode("utf-8", errors="ignore")
|
|
1322
|
+
if ch2 == "[":
|
|
1323
|
+
if not select.select([fd], [], [], 0.05)[0]:
|
|
1324
|
+
return "\x1b"
|
|
1325
|
+
third = os.read(fd, 1)
|
|
1326
|
+
if not third:
|
|
1327
|
+
return "\x1b"
|
|
1328
|
+
ch3 = third.decode("utf-8", errors="ignore")
|
|
1329
|
+
return f"\x1b[{ch3}"
|
|
1330
|
+
if ch2 == "O":
|
|
1331
|
+
if not select.select([fd], [], [], 0.05)[0]:
|
|
1332
|
+
return "\x1b"
|
|
1333
|
+
third = os.read(fd, 1)
|
|
1334
|
+
if not third:
|
|
1335
|
+
return "\x1b"
|
|
1336
|
+
ch3 = third.decode("utf-8", errors="ignore")
|
|
1337
|
+
return f"\x1bO{ch3}"
|
|
1338
|
+
return "\x1b"
|
|
1339
|
+
return ch
|
|
1340
|
+
finally:
|
|
1341
|
+
try:
|
|
1342
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
1343
|
+
finally:
|
|
1344
|
+
os.close(fd)
|
|
1345
|
+
|
|
1346
|
+
def _prompt_approval_selector(self) -> str:
|
|
1347
|
+
options = self._approval_selector_options()
|
|
1348
|
+
selected_index = 0
|
|
1349
|
+
self._render_approval_selector_menu(selected_index=selected_index)
|
|
1350
|
+
menu_visible = True
|
|
1351
|
+
try:
|
|
1352
|
+
while True:
|
|
1353
|
+
try:
|
|
1354
|
+
key = self._read_approval_selector_keypress()
|
|
1355
|
+
except (EOFError, KeyboardInterrupt):
|
|
1356
|
+
key = "n"
|
|
1357
|
+
except Exception:
|
|
1358
|
+
if menu_visible:
|
|
1359
|
+
self._erase_approval_selector_menu()
|
|
1360
|
+
menu_visible = False
|
|
1361
|
+
return self._prompt_approval_choice_fallback()
|
|
1362
|
+
|
|
1363
|
+
lowered = key.lower() if key else ""
|
|
1364
|
+
if key in {"\r", "\n"}:
|
|
1365
|
+
choice = options[selected_index][0]
|
|
1366
|
+
break
|
|
1367
|
+
if lowered in {"y", "a", "n", "v"}:
|
|
1368
|
+
choice = lowered
|
|
1369
|
+
break
|
|
1370
|
+
if lowered == "k" or key == "\x1b[A":
|
|
1371
|
+
selected_index = (selected_index - 1) % len(options)
|
|
1372
|
+
elif lowered == "j" or key == "\x1b[B":
|
|
1373
|
+
selected_index = (selected_index + 1) % len(options)
|
|
1374
|
+
elif key in {"\x1b[H", "\x1bOH"}:
|
|
1375
|
+
selected_index = 0
|
|
1376
|
+
elif key in {"\x1b[F", "\x1bOF"}:
|
|
1377
|
+
selected_index = len(options) - 1
|
|
1378
|
+
elif key in {"\x03", "\x04", "\x1b"}:
|
|
1379
|
+
choice = "n"
|
|
1380
|
+
break
|
|
1381
|
+
else:
|
|
1382
|
+
continue
|
|
1383
|
+
self._erase_approval_selector_menu()
|
|
1384
|
+
menu_visible = False
|
|
1385
|
+
self._render_approval_selector_menu(selected_index=selected_index)
|
|
1386
|
+
menu_visible = True
|
|
1387
|
+
finally:
|
|
1388
|
+
if menu_visible:
|
|
1389
|
+
self._erase_approval_selector_menu()
|
|
1390
|
+
|
|
1391
|
+
confirmation = self._approval_confirmation(choice)
|
|
1392
|
+
if confirmation is not None:
|
|
1393
|
+
message, style = confirmation
|
|
1394
|
+
self.console.print(message, style=style, highlight=False)
|
|
1395
|
+
return choice
|
|
1396
|
+
|
|
1397
|
+
def _prompt_approval_choice(self) -> str:
|
|
1398
|
+
if self._uses_inline_approval_selector():
|
|
1399
|
+
with interactive_prompt_guard(owns_terminal=True):
|
|
1400
|
+
return self._prompt_approval_selector()
|
|
1401
|
+
with interactive_prompt_guard():
|
|
1402
|
+
return self._prompt_approval_choice_fallback()
|
|
1403
|
+
|
|
1404
|
+
def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
|
1405
|
+
self._stop_thinking_spinner()
|
|
1406
|
+
scope_info = approval_session_scope_for_request(request)
|
|
1407
|
+
if scope_info.key is not None and scope_info.key in self._allow_for_session:
|
|
1408
|
+
return ApprovalDecision(allow=True, allow_for_session=True)
|
|
1409
|
+
|
|
1410
|
+
files = ", ".join(request.files[:8]) if request.files else "-"
|
|
1411
|
+
command = request.command or "-"
|
|
1412
|
+
preview = _redact(request.preview or "")
|
|
1413
|
+
preview_available = bool(preview.strip())
|
|
1414
|
+
lowered_command = command.lower()
|
|
1415
|
+
destructive = request.kind == "fs_delete" or any(
|
|
1416
|
+
token in lowered_command for token in (" rm ", "rm -", "force", "delete")
|
|
1417
|
+
)
|
|
1418
|
+
bar_style = "red" if destructive else _STYLE_EMPHASIS
|
|
1419
|
+
title_style = "red" if destructive else _STYLE_EMPHASIS
|
|
1420
|
+
target = command if request.command else files
|
|
1421
|
+
if request.kind.startswith("custom_tool_run:"):
|
|
1422
|
+
action_label = "Run custom tool"
|
|
1423
|
+
else:
|
|
1424
|
+
action_label = {
|
|
1425
|
+
"shell_run": "Run command",
|
|
1426
|
+
"fs_write": "Write files",
|
|
1427
|
+
"fs_delete": "Delete files",
|
|
1428
|
+
}.get(request.kind, f"Approve {request.kind}")
|
|
1429
|
+
headline = action_label
|
|
1430
|
+
if request.reason:
|
|
1431
|
+
headline += f"{_separator(self.console)}{request.reason}"
|
|
1432
|
+
|
|
1433
|
+
self.console.print(
|
|
1434
|
+
_prefixed_text(
|
|
1435
|
+
prefix=_bar_prefix(self.console),
|
|
1436
|
+
prefix_style=bar_style,
|
|
1437
|
+
text=headline,
|
|
1438
|
+
text_style=title_style,
|
|
1439
|
+
),
|
|
1440
|
+
highlight=False,
|
|
1441
|
+
)
|
|
1442
|
+
self.console.print(
|
|
1443
|
+
_prefixed_text(
|
|
1444
|
+
prefix=_bar_prefix(self.console),
|
|
1445
|
+
prefix_style=bar_style,
|
|
1446
|
+
text=target,
|
|
1447
|
+
text_style=_STYLE_CONTENT,
|
|
1448
|
+
),
|
|
1449
|
+
highlight=False,
|
|
1450
|
+
)
|
|
1451
|
+
if request.command and request.files:
|
|
1452
|
+
self.console.print(
|
|
1453
|
+
_prefixed_text(
|
|
1454
|
+
prefix=" ",
|
|
1455
|
+
prefix_style=_STYLE_CONTENT,
|
|
1456
|
+
text=f"Files: {files}",
|
|
1457
|
+
text_style=_STYLE_META,
|
|
1458
|
+
),
|
|
1459
|
+
highlight=False,
|
|
1460
|
+
)
|
|
1461
|
+
context_line = (
|
|
1462
|
+
f"Context available ({len(preview)} chars) · [v] view"
|
|
1463
|
+
if preview_available
|
|
1464
|
+
else "Context none"
|
|
1465
|
+
)
|
|
1466
|
+
if preview_available:
|
|
1467
|
+
self.console.print(
|
|
1468
|
+
Text.assemble(
|
|
1469
|
+
(" ", _STYLE_CONTENT),
|
|
1470
|
+
("Context available ", _STYLE_META),
|
|
1471
|
+
("[v]", _STYLE_EMPHASIS),
|
|
1472
|
+
(" to view", _STYLE_META),
|
|
1473
|
+
),
|
|
1474
|
+
highlight=False,
|
|
1475
|
+
)
|
|
1476
|
+
else:
|
|
1477
|
+
self.console.print(
|
|
1478
|
+
_prefixed_text(
|
|
1479
|
+
prefix=" ",
|
|
1480
|
+
prefix_style=_STYLE_CONTENT,
|
|
1481
|
+
text=context_line,
|
|
1482
|
+
text_style=_STYLE_META,
|
|
1483
|
+
),
|
|
1484
|
+
highlight=False,
|
|
1485
|
+
)
|
|
1486
|
+
if not self._uses_inline_approval_selector():
|
|
1487
|
+
options = [
|
|
1488
|
+
(" ", _STYLE_CONTENT),
|
|
1489
|
+
("[y]", _STYLE_EMPHASIS),
|
|
1490
|
+
(" allow ", _STYLE_META),
|
|
1491
|
+
]
|
|
1492
|
+
if scope_info.supported:
|
|
1493
|
+
options.extend(
|
|
1494
|
+
[
|
|
1495
|
+
("[a]", _STYLE_EMPHASIS),
|
|
1496
|
+
(" always ", _STYLE_META),
|
|
1497
|
+
]
|
|
1498
|
+
)
|
|
1499
|
+
options.extend(
|
|
1500
|
+
[
|
|
1501
|
+
("[n]", _STYLE_EMPHASIS),
|
|
1502
|
+
(" deny ", _STYLE_META),
|
|
1503
|
+
("[v]", _STYLE_EMPHASIS),
|
|
1504
|
+
(" view", _STYLE_META),
|
|
1505
|
+
]
|
|
1506
|
+
)
|
|
1507
|
+
self.console.print(Text.assemble(*options), highlight=False)
|
|
1508
|
+
elif scope_info.warning:
|
|
1509
|
+
self.console.print(
|
|
1510
|
+
_prefixed_text(
|
|
1511
|
+
prefix=" ",
|
|
1512
|
+
prefix_style=_STYLE_CONTENT,
|
|
1513
|
+
text=scope_info.warning,
|
|
1514
|
+
text_style=_STYLE_META,
|
|
1515
|
+
),
|
|
1516
|
+
highlight=False,
|
|
1517
|
+
)
|
|
1518
|
+
while True:
|
|
1519
|
+
choice = self._prompt_approval_choice()
|
|
1520
|
+
if choice in {"1", "y"}:
|
|
1521
|
+
return ApprovalDecision(allow=True)
|
|
1522
|
+
if choice in {"2", "a"}:
|
|
1523
|
+
if scope_info.supported and scope_info.key is not None:
|
|
1524
|
+
self._allow_for_session.add(scope_info.key)
|
|
1525
|
+
return ApprovalDecision(allow=True, allow_for_session=True)
|
|
1526
|
+
self.console.print(
|
|
1527
|
+
_prefixed_text(
|
|
1528
|
+
prefix=" ",
|
|
1529
|
+
prefix_style=_STYLE_CONTENT,
|
|
1530
|
+
text=scope_info.warning
|
|
1531
|
+
or "Allow for session is unavailable for this approval.",
|
|
1532
|
+
text_style=_STYLE_META,
|
|
1533
|
+
),
|
|
1534
|
+
highlight=False,
|
|
1535
|
+
)
|
|
1536
|
+
return ApprovalDecision(allow=True)
|
|
1537
|
+
if choice in {"3", "n"}:
|
|
1538
|
+
return ApprovalDecision(allow=False)
|
|
1539
|
+
if choice in {"4", "v"}:
|
|
1540
|
+
self.console.print(
|
|
1541
|
+
_prefixed_text(
|
|
1542
|
+
prefix=_bar_prefix(self.console),
|
|
1543
|
+
prefix_style=_STYLE_CHROME,
|
|
1544
|
+
text="Context",
|
|
1545
|
+
text_style=_STYLE_EMPHASIS,
|
|
1546
|
+
),
|
|
1547
|
+
highlight=False,
|
|
1548
|
+
)
|
|
1549
|
+
self._print_left_bar_block(
|
|
1550
|
+
_split_lines(_redact(request.preview or "") or "(no preview)"),
|
|
1551
|
+
bar_style=_STYLE_CHROME,
|
|
1552
|
+
text_style=_STYLE_CONTENT,
|
|
1553
|
+
)
|
|
1554
|
+
continue
|
|
1555
|
+
self.console.print("Enter y, a, n, or v.", style="red", highlight=False)
|