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,109 @@
|
|
|
1
|
+
"""Owl ASCII animation for the TUI.
|
|
2
|
+
|
|
3
|
+
This reuses the *exact* owl frames and loaders from the welcome screen
|
|
4
|
+
(``cli_impl/commands/welcome.py``) so the launch animation is identical to the
|
|
5
|
+
one the classic CLI shows. We only change how the frames are driven: instead of
|
|
6
|
+
raw ``time.sleep`` + cursor-up ANSI writes, the prompt_toolkit event loop
|
|
7
|
+
advances the frame index and repaints.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
17
|
+
|
|
18
|
+
# Render the owl as a crisp *white* silhouette on a dark terminal — without
|
|
19
|
+
# painting a background box. The source art is a single grayscale ramp (index 16
|
|
20
|
+
# plus the 232–255 gray ramp, with a stray system grey 7). The old mapping only
|
|
21
|
+
# lifted that ramp into 250–255, which left the owl reading as a washed-out light
|
|
22
|
+
# grey (#bcbcbc) and — worse — squashed the beak's faint mid-grey marks to within
|
|
23
|
+
# one step of the body, so the nose showed up as a smudge. Folding the whole ramp
|
|
24
|
+
# to pure white fixes both: the silhouette is genuinely white, and the beak/eyes
|
|
25
|
+
# now read cleanly because their shape is carried by the block-glyph geometry and
|
|
26
|
+
# the dark background showing through, not by tiny grey-on-grey contrast.
|
|
27
|
+
_OWL_WHITE = 231 # xterm-256 pure white (#ffffff)
|
|
28
|
+
_FG_256_RE = re.compile(r"\x1b\[38;5;(\d+)m")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _whiten_index(idx: int) -> int:
|
|
32
|
+
# Any grey the owl art uses → pure white; leave genuine colours untouched.
|
|
33
|
+
if idx in (7, 8, 16) or 232 <= idx <= 255:
|
|
34
|
+
return _OWL_WHITE
|
|
35
|
+
return idx
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _whiten_frames(frames: list[list[str]]) -> list[list[str]]:
|
|
39
|
+
def _sub(match: re.Match[str]) -> str:
|
|
40
|
+
return f"\x1b[38;5;{_whiten_index(int(match.group(1)))}m"
|
|
41
|
+
|
|
42
|
+
return [[_FG_256_RE.sub(_sub, line) for line in frame] for frame in frames]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_frames(*, color_enabled: bool, stream: Any | None) -> list[list[str]]:
|
|
46
|
+
"""Load + crop the owl frames, returning [] when disabled or unavailable.
|
|
47
|
+
|
|
48
|
+
Theme detection (which may probe the terminal via OSC 11) happens here, so
|
|
49
|
+
callers must invoke this *before* the prompt_toolkit application takes over
|
|
50
|
+
the terminal.
|
|
51
|
+
"""
|
|
52
|
+
target_stream = stream if stream is not None else getattr(sys, "stdout", None)
|
|
53
|
+
try:
|
|
54
|
+
from ..commands.welcome import ( # local import avoids cli_surface cycles
|
|
55
|
+
_crop_owl_logo_frames,
|
|
56
|
+
_detect_owl_theme,
|
|
57
|
+
_load_owl_logo_frames,
|
|
58
|
+
)
|
|
59
|
+
except Exception:
|
|
60
|
+
return []
|
|
61
|
+
try:
|
|
62
|
+
theme = _detect_owl_theme(target_stream)
|
|
63
|
+
frames = _load_owl_logo_frames(
|
|
64
|
+
stream=target_stream,
|
|
65
|
+
color_enabled=color_enabled,
|
|
66
|
+
theme=theme,
|
|
67
|
+
)
|
|
68
|
+
frames = _crop_owl_logo_frames(frames)
|
|
69
|
+
# White silhouette (no background box) on a confirmed dark terminal.
|
|
70
|
+
# Light terminals keep the asset's dark grays; neutral frames were
|
|
71
|
+
# stripped to the terminal's own foreground by the shared loader.
|
|
72
|
+
if color_enabled and theme == "dark":
|
|
73
|
+
frames = _whiten_frames(frames)
|
|
74
|
+
except Exception:
|
|
75
|
+
return []
|
|
76
|
+
return [frame for frame in (frames or []) if frame]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class OwlAnimation:
|
|
80
|
+
"""Holds the owl frames and the current frame index."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, frames: list[list[str]]):
|
|
83
|
+
self._frames = frames or []
|
|
84
|
+
self._index = 0
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def available(self) -> bool:
|
|
88
|
+
return bool(self._frames)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def frame_count(self) -> int:
|
|
92
|
+
return len(self._frames)
|
|
93
|
+
|
|
94
|
+
def advance(self) -> None:
|
|
95
|
+
if self._frames:
|
|
96
|
+
self._index = (self._index + 1) % len(self._frames)
|
|
97
|
+
|
|
98
|
+
def current_ansi(self) -> ANSI | None:
|
|
99
|
+
if not self._frames:
|
|
100
|
+
return None
|
|
101
|
+
frame = self._frames[self._index]
|
|
102
|
+
return ANSI("\n".join(frame))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_owl_animation(*, color_enabled: bool = True, stream: Any | None = None) -> OwlAnimation:
|
|
106
|
+
return OwlAnimation(_load_frames(color_enabled=color_enabled, stream=stream))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = ["OwlAnimation", "load_owl_animation"]
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Structured rendering for the Forge planner's plan-update aside.
|
|
2
|
+
|
|
3
|
+
While Forge mode chats with the planner, the TUI captures the turn's console
|
|
4
|
+
output (the ``│ ``-prefixed meta/warning bar lines that ``_print_forge_meta``
|
|
5
|
+
and ``_print_forge_warning_messages`` emit) and shows it beneath the reply as
|
|
6
|
+
a collapsible aside. This module turns that captured text back into logical
|
|
7
|
+
notes and renders them as a structured block instead of a flat dim dump:
|
|
8
|
+
|
|
9
|
+
* the canonical status line becomes an honest headline (``plan updated`` /
|
|
10
|
+
``plan unchanged`` / ``planner error``) instead of the first note,
|
|
11
|
+
* notes about the same task are grouped under one bright title (the single
|
|
12
|
+
biggest source of the old wall-of-text was the task title repeated in
|
|
13
|
+
every warning),
|
|
14
|
+
* planner warnings carry an amber ``⚠`` mark, info notes a dim ``·``, and the
|
|
15
|
+
``▸``/``▾`` chevrons take the Forge violet so the aside reads as Forge UI.
|
|
16
|
+
|
|
17
|
+
The note grammar matched here is produced by this repo's own emit sites
|
|
18
|
+
(``forge_helpers`` / ``plan_assistant`` / ``task_readiness`` /
|
|
19
|
+
``plan_reconciliation``); a source-contract test keeps the literals in sync.
|
|
20
|
+
Unrecognized notes degrade gracefully to plain dim bullets — parsing can only
|
|
21
|
+
ever *improve* the presentation, never lose a note.
|
|
22
|
+
|
|
23
|
+
Every rendered row is at most ``width`` columns (the transcript's cursor-pin
|
|
24
|
+
scroll math depends on logical rows matching screen rows).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
|
|
32
|
+
# Style classes (declared in ``app._STYLE``).
|
|
33
|
+
_S_MARK = "class:tui.planmeta.mark"
|
|
34
|
+
_S_HEAD = "class:tui.planmeta.head"
|
|
35
|
+
_S_DIM = "class:tui.planmeta.dim"
|
|
36
|
+
_S_SUBJECT = "class:tui.planmeta.subject"
|
|
37
|
+
_S_TAG = "class:tui.planmeta.tag"
|
|
38
|
+
_S_WARN = "class:tui.planmeta.warn"
|
|
39
|
+
_S_HINT = "class:tui.planmeta.hint"
|
|
40
|
+
|
|
41
|
+
# The bar prefix ``_forge_bar_text`` puts in front of every console meta /
|
|
42
|
+
# warning line (the rail marks where each logical note starts in a capture).
|
|
43
|
+
_BAR = "│"
|
|
44
|
+
|
|
45
|
+
# Warning-group labels used on the planner turn path (``emit_warning_group``
|
|
46
|
+
# call sites in ``forge_helpers``).
|
|
47
|
+
_WARN_LABELS = ("Planner", "Plan reconciliation")
|
|
48
|
+
|
|
49
|
+
# Canonical status lines the planner turn controller emits, matched by prefix
|
|
50
|
+
# so a small wording tweak degrades to a generic note rather than a wrong
|
|
51
|
+
# headline. The matching note folds into the headline instead of repeating —
|
|
52
|
+
# except the "ignored" line, whose tail carries the actionable reason and is
|
|
53
|
+
# kept as an info note (minus the "; plan unchanged." suffix the headline
|
|
54
|
+
# already states).
|
|
55
|
+
_STATUS_HEADLINES = (
|
|
56
|
+
("Applied planner update", "plan updated", False),
|
|
57
|
+
("Planner update contained no applicable changes", "plan unchanged", False),
|
|
58
|
+
("Planner update ignored", "plan unchanged", True),
|
|
59
|
+
)
|
|
60
|
+
_ERROR_NOTE_PREFIX = "Final planner error"
|
|
61
|
+
_DEFAULT_HEADLINE = "planner notes"
|
|
62
|
+
_ERROR_HEADLINE = "planner error"
|
|
63
|
+
|
|
64
|
+
# Subject extraction: pull the task a note talks about out of the note text so
|
|
65
|
+
# repeated titles collapse into one group header. Subject captures are LAZY
|
|
66
|
+
# (``.+?``) so the FIRST ``': `` boundary closes the title: the emit sites
|
|
67
|
+
# write ``'<title>': `` with the quote-colon immediately after the title,
|
|
68
|
+
# while everything AFTER that boundary is verbatim model output (echoed
|
|
69
|
+
# dependency ids, invalid path entries) that can itself contain quotes and
|
|
70
|
+
# colons — a greedy capture would let such junk swallow the subject and the
|
|
71
|
+
# actionable detail. Titles with plain apostrophes still parse whole (an
|
|
72
|
+
# apostrophe alone is not a boundary); the pathological title containing a
|
|
73
|
+
# literal ``': `` truncates at its first boundary — still grouped, with the
|
|
74
|
+
# tail kept in the detail, never lost.
|
|
75
|
+
_DROPPED_RES = (
|
|
76
|
+
re.compile(
|
|
77
|
+
r"^Dropped (?P<what>[a-z -]+) dependencies for (?P<target>.+?) "
|
|
78
|
+
r"'(?P<subject>.+?)':\s*(?P<rest>.*)$"
|
|
79
|
+
),
|
|
80
|
+
re.compile(r"^Dropped (?P<what>[a-z -]+) dependencies for (?P<target>.+?) '(?P<subject>.+)'$"),
|
|
81
|
+
)
|
|
82
|
+
_SUBJECT_PREFIX_RES = (
|
|
83
|
+
(re.compile(r"^New task '(?P<subject>.+?)':\s*(?P<rest>.+)$"), "new"),
|
|
84
|
+
(re.compile(r"^Updated task '(?P<subject>.+?)':\s*(?P<rest>.+)$"), "updated"),
|
|
85
|
+
(re.compile(r"^Task '(?P<subject>.+?)':\s*(?P<rest>.+)$"), "task"),
|
|
86
|
+
(re.compile(r"^Task (?P<subject>[\w.-]+):\s*(?P<rest>.+)$"), "task"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
_SUBJECT_GLYPHS = {"new": "+", "follow-up": "+", "updated": "~", "task": "○"}
|
|
90
|
+
_SUBJECT_TAGS = {
|
|
91
|
+
"new": "new task",
|
|
92
|
+
"follow-up": "follow-up task",
|
|
93
|
+
"updated": "updated task",
|
|
94
|
+
"task": "task",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# UI decoration on rendered rows (stripped again on clipboard copy).
|
|
98
|
+
_EXPAND_HINT = " · click to expand"
|
|
99
|
+
_GLYPH_STRIP_RE = re.compile(r"^(\s*)[▸▾+~○·⚠] (.*)$")
|
|
100
|
+
# Shortest hint fragment worth stripping from a sweep released mid-hint: the
|
|
101
|
+
# distinctive " · " lead-in (a rendered note never ends with it).
|
|
102
|
+
_HINT_MIN_FRAGMENT = 5
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def strip_plan_meta_copy_chrome(text: str, *, starts_row: bool = True) -> str:
|
|
106
|
+
"""Return a rendered plan-meta row's text without its UI decoration — the
|
|
107
|
+
chevron/bullet/warning glyph and the expand hint — keeping the indentation
|
|
108
|
+
so a copied block preserves its note hierarchy.
|
|
109
|
+
|
|
110
|
+
``starts_row`` is False for a selection sweep that begins mid-row: its
|
|
111
|
+
first character is note content, never a lead glyph, so only the hint
|
|
112
|
+
suffix is stripped. A sweep released inside the hint drops the trailing
|
|
113
|
+
hint fragment too (the fragment is still chrome). Wrap-continuation rows
|
|
114
|
+
must NOT be routed here at all — their first character is content (see
|
|
115
|
+
``ROW_KIND_CONT``)."""
|
|
116
|
+
text = str(text or "")
|
|
117
|
+
for length in range(len(_EXPAND_HINT), _HINT_MIN_FRAGMENT - 1, -1):
|
|
118
|
+
if text.endswith(_EXPAND_HINT[:length]):
|
|
119
|
+
text = text[:-length]
|
|
120
|
+
break
|
|
121
|
+
if not starts_row:
|
|
122
|
+
return text
|
|
123
|
+
match = _GLYPH_STRIP_RE.match(text)
|
|
124
|
+
if match:
|
|
125
|
+
return f"{match.group(1)}{match.group(2)}"
|
|
126
|
+
return text
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class PlanMetaNote:
|
|
131
|
+
"""One logical planner note, classified for display."""
|
|
132
|
+
|
|
133
|
+
kind: str = "info" # "info" | "warn"
|
|
134
|
+
label: str = "" # warning-group label ("Planner", "Plan reconciliation")
|
|
135
|
+
subject: str = "" # task title/id the note is about ("" = none)
|
|
136
|
+
subject_kind: str = "" # "new" | "updated" | "follow-up" | "task"
|
|
137
|
+
detail: str = "" # note text with the label + subject phrase folded out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class _SubjectGroup:
|
|
142
|
+
subject: str
|
|
143
|
+
subject_kind: str
|
|
144
|
+
details: list[PlanMetaNote] = field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class PlanMetaView:
|
|
149
|
+
headline: str
|
|
150
|
+
notes: list[PlanMetaNote]
|
|
151
|
+
entries: list[tuple[str, PlanMetaNote | _SubjectGroup]]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_rule(line: str) -> bool:
|
|
155
|
+
return len(line) >= 3 and not set(line) - set("─-–—═ ")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _logical_lines(text: str) -> list[str]:
|
|
159
|
+
"""Reassemble logical notes from captured console text.
|
|
160
|
+
|
|
161
|
+
Bar-prefixed lines start a note; bare lines are wrap continuations from a
|
|
162
|
+
narrow capture console and re-join the note above them. Text with no bar
|
|
163
|
+
rail at all (already-clean note lines) keeps one note per line.
|
|
164
|
+
"""
|
|
165
|
+
raw = [line.strip() for line in str(text or "").split("\n")]
|
|
166
|
+
raw = [line for line in raw if line and not _is_rule(line)]
|
|
167
|
+
has_bar = any(line.startswith(_BAR) for line in raw)
|
|
168
|
+
notes: list[str] = []
|
|
169
|
+
for line in raw:
|
|
170
|
+
if not has_bar:
|
|
171
|
+
notes.append(line)
|
|
172
|
+
elif line.startswith(_BAR):
|
|
173
|
+
content = line[len(_BAR) :].strip()
|
|
174
|
+
if not _is_rule(content): # a bar-prefixed rule is still chrome
|
|
175
|
+
notes.append(content)
|
|
176
|
+
elif notes:
|
|
177
|
+
notes[-1] = f"{notes[-1]} {line}"
|
|
178
|
+
else:
|
|
179
|
+
notes.append(line)
|
|
180
|
+
return [note for note in (" ".join(n.split()) for n in notes) if note]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _dropped_target_kind(target: str) -> str:
|
|
184
|
+
"""Map a dropped-dependencies target phrase to a subject kind — e.g.
|
|
185
|
+
``new task`` / ``planner tasks_add`` → new, ``synthesized follow-up task``
|
|
186
|
+
→ follow-up, ``planner tasks_update`` → updated."""
|
|
187
|
+
lowered = target.lower()
|
|
188
|
+
if "follow-up" in lowered:
|
|
189
|
+
return "follow-up"
|
|
190
|
+
if "new task" in lowered or "tasks_add" in lowered:
|
|
191
|
+
return "new"
|
|
192
|
+
if "updated task" in lowered or "tasks_update" in lowered:
|
|
193
|
+
return "updated"
|
|
194
|
+
return "task"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _classify(text: str) -> PlanMetaNote:
|
|
198
|
+
kind, label, body = "info", "", text
|
|
199
|
+
for warn_label in _WARN_LABELS:
|
|
200
|
+
prefix = f"{warn_label}: "
|
|
201
|
+
if body.startswith(prefix):
|
|
202
|
+
kind, label, body = "warn", warn_label, body[len(prefix) :].strip()
|
|
203
|
+
break
|
|
204
|
+
subject = subject_kind = ""
|
|
205
|
+
detail = body
|
|
206
|
+
match = next((m for pattern in _DROPPED_RES if (m := pattern.match(body))), None)
|
|
207
|
+
if match:
|
|
208
|
+
subject = match.group("subject")
|
|
209
|
+
subject_kind = _dropped_target_kind(match.group("target"))
|
|
210
|
+
what = match.group("what").strip()
|
|
211
|
+
rest = (match.groupdict().get("rest") or "").strip()
|
|
212
|
+
detail = f"dropped {what} dependencies"
|
|
213
|
+
if rest:
|
|
214
|
+
detail = f"{detail}: {rest}"
|
|
215
|
+
else:
|
|
216
|
+
for pattern, pat_kind in _SUBJECT_PREFIX_RES:
|
|
217
|
+
match = pattern.match(body)
|
|
218
|
+
if match:
|
|
219
|
+
subject = match.group("subject")
|
|
220
|
+
subject_kind = pat_kind
|
|
221
|
+
detail = match.group("rest").strip()
|
|
222
|
+
break
|
|
223
|
+
return PlanMetaNote(
|
|
224
|
+
kind=kind, label=label, subject=subject, subject_kind=subject_kind, detail=detail
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse_plan_meta(text: str) -> PlanMetaView:
|
|
229
|
+
"""Parse captured planner console text into a headline + grouped notes."""
|
|
230
|
+
headline = ""
|
|
231
|
+
notes: list[PlanMetaNote] = []
|
|
232
|
+
for line in _logical_lines(text):
|
|
233
|
+
if not headline:
|
|
234
|
+
matched = next(
|
|
235
|
+
(
|
|
236
|
+
(title, keep_reason)
|
|
237
|
+
for prefix, title, keep_reason in _STATUS_HEADLINES
|
|
238
|
+
if line.startswith(prefix)
|
|
239
|
+
),
|
|
240
|
+
None,
|
|
241
|
+
)
|
|
242
|
+
if matched is not None:
|
|
243
|
+
headline, keep_reason = matched
|
|
244
|
+
if keep_reason:
|
|
245
|
+
# Keep the actionable tail (e.g. WHY the update was
|
|
246
|
+
# ignored); the headline already says "plan unchanged".
|
|
247
|
+
reason = line.removesuffix("; plan unchanged.").strip()
|
|
248
|
+
if reason:
|
|
249
|
+
notes.append(_classify(reason))
|
|
250
|
+
continue # the status line IS the headline; don't repeat it
|
|
251
|
+
notes.append(_classify(line))
|
|
252
|
+
if not headline:
|
|
253
|
+
error_note = any(
|
|
254
|
+
note.kind == "warn" and note.detail.startswith(_ERROR_NOTE_PREFIX) for note in notes
|
|
255
|
+
)
|
|
256
|
+
headline = _ERROR_HEADLINE if error_note else _DEFAULT_HEADLINE
|
|
257
|
+
|
|
258
|
+
entries: list[tuple[str, PlanMetaNote | _SubjectGroup]] = []
|
|
259
|
+
groups: dict[str, _SubjectGroup] = {}
|
|
260
|
+
for note in notes:
|
|
261
|
+
if not note.subject:
|
|
262
|
+
entries.append(("note", note))
|
|
263
|
+
continue
|
|
264
|
+
group = groups.get(note.subject)
|
|
265
|
+
if group is None:
|
|
266
|
+
group = _SubjectGroup(subject=note.subject, subject_kind=note.subject_kind)
|
|
267
|
+
groups[note.subject] = group
|
|
268
|
+
entries.append(("group", group))
|
|
269
|
+
elif group.subject_kind in {"", "task"} and note.subject_kind not in {"", "task"}:
|
|
270
|
+
# A later note names the touched task more precisely (new/updated).
|
|
271
|
+
group.subject_kind = note.subject_kind
|
|
272
|
+
group.details.append(note)
|
|
273
|
+
return PlanMetaView(headline=headline, notes=notes, entries=entries)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------ rendering
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _iter_words(segments: list[tuple[str, str]]):
|
|
280
|
+
for style, text in segments:
|
|
281
|
+
for word in text.split(" "):
|
|
282
|
+
if word:
|
|
283
|
+
yield style, word
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _wrap_segments(
|
|
287
|
+
lead: list[tuple[str, str]],
|
|
288
|
+
body: list[tuple[str, str]],
|
|
289
|
+
width: int,
|
|
290
|
+
*,
|
|
291
|
+
cont_indent: int,
|
|
292
|
+
) -> list[list[tuple[str, str]]]:
|
|
293
|
+
"""Flow styled ``body`` words after the fixed ``lead``, hanging-indenting
|
|
294
|
+
continuation rows. Every produced row is at most ``width`` columns."""
|
|
295
|
+
width = max(1, int(width))
|
|
296
|
+
cont_indent = max(0, min(int(cont_indent), width - 8)) if width > 8 else 0
|
|
297
|
+
rows: list[list[tuple[str, str]]] = []
|
|
298
|
+
# Clip the lead too, so the row-width contract holds even for a lead wider
|
|
299
|
+
# than a pathologically narrow panel.
|
|
300
|
+
cur: list[tuple[str, str]] = _fit_segments([(s, t) for s, t in lead if t], width)
|
|
301
|
+
used = sum(len(text) for _style, text in cur)
|
|
302
|
+
has_word = False
|
|
303
|
+
|
|
304
|
+
def push(style: str, text: str) -> None:
|
|
305
|
+
nonlocal used
|
|
306
|
+
if cur and cur[-1][0] == style:
|
|
307
|
+
cur[-1] = (style, cur[-1][1] + text)
|
|
308
|
+
else:
|
|
309
|
+
cur.append((style, text))
|
|
310
|
+
used += len(text)
|
|
311
|
+
|
|
312
|
+
def break_row() -> None:
|
|
313
|
+
nonlocal cur, used, has_word
|
|
314
|
+
rows.append(cur)
|
|
315
|
+
cur = [(_S_DIM, " " * cont_indent)] if cont_indent else []
|
|
316
|
+
used = cont_indent
|
|
317
|
+
has_word = False
|
|
318
|
+
|
|
319
|
+
for style, word in _iter_words(body):
|
|
320
|
+
while word:
|
|
321
|
+
room = width - used - (1 if has_word else 0)
|
|
322
|
+
if len(word) <= room:
|
|
323
|
+
if has_word:
|
|
324
|
+
push(style, " ")
|
|
325
|
+
push(style, word)
|
|
326
|
+
has_word = True
|
|
327
|
+
word = ""
|
|
328
|
+
elif not has_word and room > 0:
|
|
329
|
+
push(style, word[:room]) # hard-break a word longer than the row
|
|
330
|
+
word = word[room:]
|
|
331
|
+
break_row()
|
|
332
|
+
else:
|
|
333
|
+
break_row()
|
|
334
|
+
rows.append(cur)
|
|
335
|
+
return rows
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _fit_segments(segments: list[tuple[str, str]], width: int) -> list[tuple[str, str]]:
|
|
339
|
+
"""Clip a single styled row to ``width`` columns, ellipsizing the cut."""
|
|
340
|
+
out: list[tuple[str, str]] = []
|
|
341
|
+
used = 0
|
|
342
|
+
for style, text in segments:
|
|
343
|
+
if not text:
|
|
344
|
+
continue
|
|
345
|
+
room = width - used
|
|
346
|
+
if room <= 0:
|
|
347
|
+
break
|
|
348
|
+
if len(text) <= room:
|
|
349
|
+
out.append((style, text))
|
|
350
|
+
used += len(text)
|
|
351
|
+
else:
|
|
352
|
+
clipped = text[: room - 1].rstrip() + "…" if room >= 2 else "…"
|
|
353
|
+
out.append((style, clipped[:room]))
|
|
354
|
+
used += len(clipped)
|
|
355
|
+
break
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _summary_counts(view: PlanMetaView) -> tuple[int, int, int]:
|
|
360
|
+
new_n = sum(
|
|
361
|
+
1
|
|
362
|
+
for kind, entry in view.entries
|
|
363
|
+
if kind == "group" and entry.subject_kind in {"new", "follow-up"}
|
|
364
|
+
)
|
|
365
|
+
updated_n = sum(
|
|
366
|
+
1 for kind, entry in view.entries if kind == "group" and entry.subject_kind == "updated"
|
|
367
|
+
)
|
|
368
|
+
warn_n = sum(1 for note in view.notes if note.kind == "warn")
|
|
369
|
+
return new_n, updated_n, warn_n
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _collapsed_row(view: PlanMetaView, width: int) -> list[tuple[str, str]]:
|
|
373
|
+
n = len(view.notes)
|
|
374
|
+
if n == 0:
|
|
375
|
+
return _fit_segments([(_S_MARK, "▸ "), (_S_HEAD, view.headline)], width)
|
|
376
|
+
only = view.notes[0]
|
|
377
|
+
if view.headline == _DEFAULT_HEADLINE and n == 1 and only.kind == "info" and not only.subject:
|
|
378
|
+
# A single free-form note (e.g. "planner unavailable"): show it verbatim,
|
|
379
|
+
# exactly like the old aside did — a synthetic headline would hide it.
|
|
380
|
+
# When the note is clipped, reserve room for the expand hint so the
|
|
381
|
+
# full text stays reachable.
|
|
382
|
+
segments = [(_S_MARK, "▸ "), (_S_DIM, only.detail)]
|
|
383
|
+
if 2 + len(only.detail) <= width or width < len(_EXPAND_HINT) + 12:
|
|
384
|
+
return _fit_segments(segments, width)
|
|
385
|
+
row = _fit_segments(segments, width - len(_EXPAND_HINT))
|
|
386
|
+
row.append((_S_HINT, _EXPAND_HINT))
|
|
387
|
+
return row
|
|
388
|
+
new_n, updated_n, warn_n = _summary_counts(view)
|
|
389
|
+
# (priority, segment): lowest priority sheds first when the panel is narrow.
|
|
390
|
+
candidates: list[tuple[int, tuple[str, str]]] = [
|
|
391
|
+
(100, (_S_MARK, "▸ ")),
|
|
392
|
+
(90, (_S_HEAD, view.headline)),
|
|
393
|
+
(60, (_S_DIM, f" · {n} note{'s' if n != 1 else ''}")),
|
|
394
|
+
]
|
|
395
|
+
if new_n:
|
|
396
|
+
candidates.append((50, (_S_DIM, f" · {new_n} new task{'s' if new_n != 1 else ''}")))
|
|
397
|
+
if updated_n:
|
|
398
|
+
candidates.append((40, (_S_DIM, f" · {updated_n} updated")))
|
|
399
|
+
if warn_n:
|
|
400
|
+
candidates.append((70, (_S_WARN, f" · {warn_n} warning{'s' if warn_n != 1 else ''}")))
|
|
401
|
+
# The expand affordance outlives the task-count chips on a narrow panel —
|
|
402
|
+
# discoverability beats detail.
|
|
403
|
+
candidates.append((55, (_S_HINT, _EXPAND_HINT)))
|
|
404
|
+
while (
|
|
405
|
+
sum(len(text) for _p, (_s, text) in candidates) > width
|
|
406
|
+
and min(p for p, _seg in candidates) < 90
|
|
407
|
+
):
|
|
408
|
+
drop = min(range(len(candidates)), key=lambda i: candidates[i][0])
|
|
409
|
+
candidates.pop(drop)
|
|
410
|
+
return _fit_segments([seg for _p, seg in candidates], width)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _note_body(note: PlanMetaNote) -> list[tuple[str, str]]:
|
|
414
|
+
body: list[tuple[str, str]] = []
|
|
415
|
+
if note.kind == "warn" and note.label and not note.subject:
|
|
416
|
+
body.append((_S_TAG, f"{note.label} · "))
|
|
417
|
+
body.append((_S_DIM, note.detail))
|
|
418
|
+
return body
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _note_lead(note: PlanMetaNote, *, indent: str) -> list[tuple[str, str]]:
|
|
422
|
+
if note.kind == "warn":
|
|
423
|
+
return [(_S_DIM, indent), (_S_WARN, "⚠ ")]
|
|
424
|
+
return [(_S_DIM, f"{indent}· ")]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# Per-row kinds for ``plan_meta_rows_with_kinds``: a "planmeta" row starts a
|
|
428
|
+
# note (its first glyph is UI chrome the copy path strips), a "planmetacont"
|
|
429
|
+
# row is a wrap continuation (its first character is note CONTENT — stripping
|
|
430
|
+
# a leading '+'/'~'/'·' there would silently delete it from copies).
|
|
431
|
+
ROW_KIND_LEAD = "planmeta"
|
|
432
|
+
ROW_KIND_CONT = "planmetacont"
|
|
433
|
+
PLAN_META_ROW_ROLES = frozenset({ROW_KIND_LEAD, ROW_KIND_CONT})
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def plan_meta_rows_with_kinds(
|
|
437
|
+
text: str, width: int, *, expanded: bool
|
|
438
|
+
) -> list[tuple[list[tuple[str, str]], str]]:
|
|
439
|
+
"""Render the plan-update aside as ``(row, kind)`` pairs. Collapsed: one
|
|
440
|
+
summary row. Expanded: a header plus grouped, styled notes. Rows never
|
|
441
|
+
exceed ``width`` columns."""
|
|
442
|
+
width = max(20, int(width))
|
|
443
|
+
view = parse_plan_meta(text)
|
|
444
|
+
if not view.notes and view.headline == _DEFAULT_HEADLINE:
|
|
445
|
+
return []
|
|
446
|
+
if not expanded:
|
|
447
|
+
return [(_collapsed_row(view, width), ROW_KIND_LEAD)]
|
|
448
|
+
n = len(view.notes)
|
|
449
|
+
header = [(_S_MARK, "▾ "), (_S_HEAD, view.headline)]
|
|
450
|
+
if n:
|
|
451
|
+
header.append((_S_DIM, f" · {n} note{'s' if n != 1 else ''}"))
|
|
452
|
+
rows: list[tuple[list[tuple[str, str]], str]] = [(_fit_segments(header, width), ROW_KIND_LEAD)]
|
|
453
|
+
|
|
454
|
+
def _extend(lead: list[tuple[str, str]], body: list[tuple[str, str]], cont_indent: int) -> None:
|
|
455
|
+
wrapped = _wrap_segments(lead, body, width, cont_indent=cont_indent)
|
|
456
|
+
rows.append((wrapped[0], ROW_KIND_LEAD))
|
|
457
|
+
rows.extend((row, ROW_KIND_CONT) for row in wrapped[1:])
|
|
458
|
+
|
|
459
|
+
for kind, entry in view.entries:
|
|
460
|
+
if kind == "note":
|
|
461
|
+
_extend(_note_lead(entry, indent=" "), _note_body(entry), 4)
|
|
462
|
+
continue
|
|
463
|
+
glyph = _SUBJECT_GLYPHS.get(entry.subject_kind, "○")
|
|
464
|
+
tag = _SUBJECT_TAGS.get(entry.subject_kind, "task")
|
|
465
|
+
_extend(
|
|
466
|
+
[(_S_MARK, f" {glyph} ")],
|
|
467
|
+
[(_S_TAG, f"{tag} · "), (_S_SUBJECT, entry.subject)],
|
|
468
|
+
4,
|
|
469
|
+
)
|
|
470
|
+
for note in entry.details:
|
|
471
|
+
_extend(_note_lead(note, indent=" "), [(_S_DIM, note.detail)], 6)
|
|
472
|
+
return rows
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def plan_meta_rows(text: str, width: int, *, expanded: bool) -> list[list[tuple[str, str]]]:
|
|
476
|
+
"""Render the plan-update aside (rows only — see ``plan_meta_rows_with_kinds``)."""
|
|
477
|
+
return [row for row, _kind in plan_meta_rows_with_kinds(text, width, expanded=expanded)]
|