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,218 @@
|
|
|
1
|
+
"""The pinned footer — Alysis Code's own status grammar (not a Cline clone).
|
|
2
|
+
|
|
3
|
+
Two lines, each split left/right against the terminal width::
|
|
4
|
+
|
|
5
|
+
◇ alysis · <Model> context: <P>% left · <N> tokens · $<cost>
|
|
6
|
+
<persona> · <mode> · <user> · <workspace> · ⎇ <branch>
|
|
7
|
+
|
|
8
|
+
The execution-mode badge on line 2 is the only approval indicator: it alone says
|
|
9
|
+
what runs unattended and what stops to ask. Line 2 has no right half — the old
|
|
10
|
+
``sensitive: ask``/``sensitive: auto`` tail was a second, independent switch that
|
|
11
|
+
could auto-answer every prompt ``safe`` mode raised while the badge still read
|
|
12
|
+
``safe``, so it was folded into the modes rather than kept alongside them.
|
|
13
|
+
|
|
14
|
+
The left side is clipped (with an ellipsis) when it would collide with the
|
|
15
|
+
right side, so the layout never wraps. Returned as prompt_toolkit
|
|
16
|
+
``FormattedText`` with ``class:`` styles resolved by the application's Style.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
22
|
+
|
|
23
|
+
from .content import pretty_model_label
|
|
24
|
+
from .state import TuiState
|
|
25
|
+
|
|
26
|
+
_BRAND_MARK = "◇"
|
|
27
|
+
_BRANCH_MARK = "⎇"
|
|
28
|
+
_SUBAGENT_MARK = "↪"
|
|
29
|
+
_DOT = " · "
|
|
30
|
+
|
|
31
|
+
# Compact labels for the execution-mode badge (full names live in the /mode popup).
|
|
32
|
+
_MODE_SHORT = {
|
|
33
|
+
"review": "safe",
|
|
34
|
+
"auto": "fast",
|
|
35
|
+
"readonly": "read",
|
|
36
|
+
"fullaccess": "full",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Full-word labels for the persona badge (state.persona is empty when persona
|
|
40
|
+
# modes are disabled, which hides the half entirely). All four personas render
|
|
41
|
+
# so the Tab cycle is always visible — including landing back on code. Full
|
|
42
|
+
# words by deliberate choice: "dbg·fast" read as noise, "debug · fast" reads
|
|
43
|
+
# as state.
|
|
44
|
+
_PERSONA_SHORT = {
|
|
45
|
+
"code": "code",
|
|
46
|
+
"architect": "architect",
|
|
47
|
+
"ask": "ask",
|
|
48
|
+
"debug": "debug",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Fragments = list[tuple[str, str]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _visible_len(fragments: Fragments) -> int:
|
|
55
|
+
return sum(len(text) for _style, text in fragments)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _format_context_left(pct: float | None) -> str:
|
|
59
|
+
"""Render the context gauge honestly at integer resolution.
|
|
60
|
+
|
|
61
|
+
``None`` (no successful measurement yet) reads ``n/a`` rather than a
|
|
62
|
+
fabricated number. ``100``/``0`` appear only when the window is genuinely
|
|
63
|
+
full/empty; any partially-used window reads 1–99, so a live session never
|
|
64
|
+
rounds up to a misleading ``100`` — the honesty the sibling ``.1f`` HUD
|
|
65
|
+
keeps, made integer-clean for the footer.
|
|
66
|
+
"""
|
|
67
|
+
if pct is None:
|
|
68
|
+
return "context: n/a"
|
|
69
|
+
if pct >= 100.0:
|
|
70
|
+
return "context: 100% left"
|
|
71
|
+
if pct <= 0.0:
|
|
72
|
+
return "context: 0% left"
|
|
73
|
+
return f"context: {min(99, max(1, round(pct)))}% left"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _format_cost(state: TuiState) -> str:
|
|
77
|
+
"""Render the session cost honestly.
|
|
78
|
+
|
|
79
|
+
A dollar figure when pricing is known, else ``n/a`` — so an unmetered/free
|
|
80
|
+
model with real usage never reads as a fake ``$0.0000``. A trailing ``+N``
|
|
81
|
+
flags calls whose cost could not be metered (partial total)."""
|
|
82
|
+
if state.cost_display:
|
|
83
|
+
return state.cost_display
|
|
84
|
+
cost = state.cost_usd
|
|
85
|
+
base = "n/a" if cost is None else f"${cost:.4f}"
|
|
86
|
+
if state.cost_unknown_calls > 0:
|
|
87
|
+
return f"{base} +{state.cost_unknown_calls}"
|
|
88
|
+
return base
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _clip_fragments(fragments: Fragments, max_width: int) -> Fragments:
|
|
92
|
+
"""Trim styled fragments from the end so the visible width fits max_width."""
|
|
93
|
+
if max_width <= 0:
|
|
94
|
+
return []
|
|
95
|
+
out: Fragments = []
|
|
96
|
+
used = 0
|
|
97
|
+
for style, text in fragments:
|
|
98
|
+
if used + len(text) <= max_width:
|
|
99
|
+
out.append((style, text))
|
|
100
|
+
used += len(text)
|
|
101
|
+
continue
|
|
102
|
+
remaining = max_width - used
|
|
103
|
+
if remaining >= 2:
|
|
104
|
+
out.append((style, text[: remaining - 1] + "…"))
|
|
105
|
+
elif remaining == 1:
|
|
106
|
+
out.append((style, "…"))
|
|
107
|
+
break
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _line1(state: TuiState) -> tuple[Fragments, Fragments]:
|
|
112
|
+
left: Fragments = [
|
|
113
|
+
("class:tui.footer.mark", f"{_BRAND_MARK} "),
|
|
114
|
+
("class:tui.footer.brand", "alysis"),
|
|
115
|
+
("class:tui.footer.dim", _DOT),
|
|
116
|
+
("class:tui.footer.model", pretty_model_label(state.model_name)),
|
|
117
|
+
]
|
|
118
|
+
right: Fragments = []
|
|
119
|
+
if state.usage_hud_enabled:
|
|
120
|
+
right.extend(
|
|
121
|
+
[
|
|
122
|
+
("class:tui.footer.context", _format_context_left(state.context_pct)),
|
|
123
|
+
("class:tui.footer.dim", _DOT),
|
|
124
|
+
("class:tui.footer.value", f"{state.tokens:,} processed"),
|
|
125
|
+
("class:tui.footer.dim", _DOT),
|
|
126
|
+
("class:tui.footer.value", _format_cost(state)),
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
return left, right
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _line2(state: TuiState) -> tuple[Fragments, Fragments]:
|
|
133
|
+
left: Fragments = []
|
|
134
|
+
if state.forge_mode:
|
|
135
|
+
# A distinct violet chip so the forge session is unmistakable at a glance
|
|
136
|
+
# (green stays the chat accent). No glyph — a wide emoji would throw off the
|
|
137
|
+
# width math that keeps the footer from wrapping.
|
|
138
|
+
label = "FORGE"
|
|
139
|
+
if state.forge_run_id:
|
|
140
|
+
label = f"{label} {state.forge_run_id}"
|
|
141
|
+
left.append(("class:tui.footer.forge", label))
|
|
142
|
+
if state.exec_mode:
|
|
143
|
+
# Glanceable execution-mode badge; amber when in the unguarded full mode.
|
|
144
|
+
# A non-default persona prefixes it (e.g. "arch·read") so the gate half
|
|
145
|
+
# of the badge is never hidden by the persona half.
|
|
146
|
+
short = _MODE_SHORT.get(state.exec_mode, state.exec_mode)
|
|
147
|
+
persona_short = _PERSONA_SHORT.get(state.persona, "")
|
|
148
|
+
if persona_short:
|
|
149
|
+
short = f"{persona_short} · {short}"
|
|
150
|
+
mode_style = (
|
|
151
|
+
"class:tui.footer.mode.warn"
|
|
152
|
+
if state.exec_mode == "fullaccess"
|
|
153
|
+
else "class:tui.footer.mode"
|
|
154
|
+
)
|
|
155
|
+
if left:
|
|
156
|
+
left.append(("class:tui.footer.dim", _DOT))
|
|
157
|
+
left.append((mode_style, short))
|
|
158
|
+
active_subagents = state.active_subagents or (
|
|
159
|
+
(state.active_subagent,) if state.active_subagent else ()
|
|
160
|
+
)
|
|
161
|
+
active_subagent_count = len(active_subagents)
|
|
162
|
+
if active_subagent_count:
|
|
163
|
+
# One fixed-width badge keeps parallel children visible without letting
|
|
164
|
+
# their names crowd the rest of the footer.
|
|
165
|
+
if left:
|
|
166
|
+
left.append(("class:tui.footer.dim", _DOT))
|
|
167
|
+
noun = "subagent" if active_subagent_count == 1 else "subagents"
|
|
168
|
+
left.append(
|
|
169
|
+
(
|
|
170
|
+
"class:tui.footer.subagent",
|
|
171
|
+
f"{_SUBAGENT_MARK} {active_subagent_count} {noun}",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
if state.username:
|
|
175
|
+
if left:
|
|
176
|
+
left.append(("class:tui.footer.dim", _DOT))
|
|
177
|
+
left.append(("class:tui.footer.user", state.username))
|
|
178
|
+
if state.workspace:
|
|
179
|
+
if left:
|
|
180
|
+
left.append(("class:tui.footer.dim", _DOT))
|
|
181
|
+
left.append(("class:tui.footer.workspace", state.workspace))
|
|
182
|
+
if state.branch:
|
|
183
|
+
if left:
|
|
184
|
+
left.append(("class:tui.footer.dim", _DOT))
|
|
185
|
+
left.append(("class:tui.footer.branch", f"{_BRANCH_MARK} {state.branch}"))
|
|
186
|
+
|
|
187
|
+
# No right tail: the execution-mode badge above already states the approval
|
|
188
|
+
# policy, and a second indicator alongside it could only ever agree (noise)
|
|
189
|
+
# or disagree (a lie). Shift+Tab cycles the mode itself; the hint is
|
|
190
|
+
# intentionally not advertised in the footer.
|
|
191
|
+
right: Fragments = []
|
|
192
|
+
return left, right
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _compose(left: Fragments, right: Fragments, width: int) -> Fragments:
|
|
196
|
+
if width <= 0:
|
|
197
|
+
return []
|
|
198
|
+
# Right side has priority and is right-aligned; clip it to the full width if it
|
|
199
|
+
# alone would overflow (narrow terminals), then fit the left into what's left.
|
|
200
|
+
right = _clip_fragments(right, width)
|
|
201
|
+
right_len = _visible_len(right)
|
|
202
|
+
left = _clip_fragments(left, max(0, width - right_len - 1))
|
|
203
|
+
gap = max(0, width - _visible_len(left) - right_len)
|
|
204
|
+
return [*left, ("", " " * gap), *right]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def footer_fragments(state: TuiState, *, width: int = 80) -> FormattedText:
|
|
208
|
+
"""Build the 2-line footer as FormattedText for the given terminal width."""
|
|
209
|
+
l1_left, l1_right = _line1(state)
|
|
210
|
+
l2_left, l2_right = _line2(state)
|
|
211
|
+
fragments: Fragments = []
|
|
212
|
+
fragments.extend(_compose(l1_left, l1_right, width))
|
|
213
|
+
fragments.append(("", "\n"))
|
|
214
|
+
fragments.extend(_compose(l2_left, l2_right, width))
|
|
215
|
+
return FormattedText(fragments)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
__all__ = ["footer_fragments"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Single source of truth for Forge task-status → visual bucket/glyph mapping.
|
|
2
|
+
|
|
3
|
+
Historically the same four status sets (done / failure / obsolete / running)
|
|
4
|
+
were copy-pasted into :mod:`...tui.app`, :mod:`...tui.surface`,
|
|
5
|
+
:mod:`...commands.chat_tui_panels`, and :mod:`...commands.cli_common`, and had
|
|
6
|
+
started to drift. This module holds them ONCE so the live Forge table, the
|
|
7
|
+
``/show`` plan panel, and the end-of-run summary can never disagree about
|
|
8
|
+
whether a task is done, failed, or still running.
|
|
9
|
+
|
|
10
|
+
Kept deliberately pure (stdlib only, no package imports) so it is a leaf that
|
|
11
|
+
any layer — the transcript model, the alt-screen renderer, or the standalone
|
|
12
|
+
panel-spec builders — can import without pulling in the agent runtime or
|
|
13
|
+
prompt_toolkit, and so it stays unit-testable in isolation.
|
|
14
|
+
|
|
15
|
+
Matching is on the RAW, whitespace-stripped, lower-cased status string (the
|
|
16
|
+
same semantics the individual sites used before consolidation). Callers that
|
|
17
|
+
want canonicalisation (e.g. the plan panel) canonicalise first and pass the
|
|
18
|
+
canonical value in — canonical statuses are already lower-case, so membership
|
|
19
|
+
still resolves.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
# Terminal-success states: the task is finished and satisfied.
|
|
25
|
+
# ``completed_unverified`` (see ``...verification_repair``, spelled literally here to
|
|
26
|
+
# keep this module import-free) means the work landed but no authoritative command
|
|
27
|
+
# existed to check it. That is a completion, not a failure — a missing test runner
|
|
28
|
+
# must never render as a red ✗ against work that is on disk.
|
|
29
|
+
FORGE_DONE_STATES: frozenset[str] = frozenset({"done", "already_satisfied", "completed_unverified"})
|
|
30
|
+
|
|
31
|
+
# Terminal-failure states: the task ended blocked/rejected/interrupted.
|
|
32
|
+
FORGE_FAILURE_STATES: frozenset[str] = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"failed",
|
|
35
|
+
"verify_failed",
|
|
36
|
+
"candidate_rejected",
|
|
37
|
+
"changes_requested",
|
|
38
|
+
"merge_conflict",
|
|
39
|
+
"blocked_integration",
|
|
40
|
+
"blocked",
|
|
41
|
+
"interrupted",
|
|
42
|
+
"cancelled",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Non-executable / superseded states: excluded from done AND remaining counts.
|
|
47
|
+
FORGE_OBSOLETE_STATES: frozenset[str] = frozenset({"superseded", "invalidated"})
|
|
48
|
+
|
|
49
|
+
# Actively-executing states written to plan.json while a worker holds the task.
|
|
50
|
+
FORGE_RUNNING_STATES: frozenset[str] = frozenset({"in_progress", "running", "executing", "active"})
|
|
51
|
+
|
|
52
|
+
# Any state in which the task is no longer running (used to stop spinning a row
|
|
53
|
+
# and prune it from the live "active workers" set).
|
|
54
|
+
FORGE_TERMINAL_STATES: frozenset[str] = (
|
|
55
|
+
FORGE_DONE_STATES | FORGE_FAILURE_STATES | FORGE_OBSOLETE_STATES
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def forge_status_bucket(status: str) -> str:
|
|
60
|
+
"""Map a task status to a visual bucket.
|
|
61
|
+
|
|
62
|
+
Returns one of ``"done" | "failed" | "obsolete" | "running" | "remaining"``.
|
|
63
|
+
``"remaining"`` covers planned/todo/unknown states (everything not otherwise
|
|
64
|
+
classified).
|
|
65
|
+
"""
|
|
66
|
+
s = str(status or "").strip().lower()
|
|
67
|
+
if s in FORGE_DONE_STATES:
|
|
68
|
+
return "done"
|
|
69
|
+
if s in FORGE_FAILURE_STATES:
|
|
70
|
+
return "failed"
|
|
71
|
+
if s in FORGE_OBSOLETE_STATES:
|
|
72
|
+
return "obsolete"
|
|
73
|
+
if s in FORGE_RUNNING_STATES:
|
|
74
|
+
return "running"
|
|
75
|
+
return "remaining"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def forge_status_is_terminal(status: str) -> bool:
|
|
79
|
+
"""True when the task is no longer running (done, failed, or obsolete)."""
|
|
80
|
+
return str(status or "").strip().lower() in FORGE_TERMINAL_STATES
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def forge_status_glyph(status: str, *, active: bool = False, spinner: str = "") -> str:
|
|
84
|
+
"""Return the one-column glyph for a task row.
|
|
85
|
+
|
|
86
|
+
``✓`` done · ``✗`` failed · ``·`` obsolete · a spinner frame for a running
|
|
87
|
+
(or explicitly-active) task · ``○`` for everything still to do. ``active``
|
|
88
|
+
is the live-view flag for "the swarm is working this right now" — it spins a
|
|
89
|
+
task whose on-disk status has not yet flipped to a running state, but a
|
|
90
|
+
terminal (done/failed) status always wins so a finished row never spins.
|
|
91
|
+
"""
|
|
92
|
+
bucket = forge_status_bucket(status)
|
|
93
|
+
if active and bucket not in ("done", "failed", "obsolete"):
|
|
94
|
+
return spinner or "◐"
|
|
95
|
+
if bucket == "done":
|
|
96
|
+
return "✓"
|
|
97
|
+
if bucket == "failed":
|
|
98
|
+
return "✗"
|
|
99
|
+
if bucket == "obsolete":
|
|
100
|
+
return "·"
|
|
101
|
+
if bucket == "running":
|
|
102
|
+
return spinner or "◐"
|
|
103
|
+
return "○"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def forge_status_counts(statuses: object) -> tuple[int, int, int]:
|
|
107
|
+
"""Return ``(done, failed, remaining)`` for an iterable of status strings.
|
|
108
|
+
|
|
109
|
+
Obsolete states are excluded from all three buckets (they are neither done
|
|
110
|
+
nor outstanding) — matching ``cli_common._forge_task_status_counts``.
|
|
111
|
+
"""
|
|
112
|
+
done = failed = remaining = 0
|
|
113
|
+
for status in statuses or ():
|
|
114
|
+
bucket = forge_status_bucket(str(status))
|
|
115
|
+
if bucket == "done":
|
|
116
|
+
done += 1
|
|
117
|
+
elif bucket == "failed":
|
|
118
|
+
failed += 1
|
|
119
|
+
elif bucket == "obsolete":
|
|
120
|
+
continue
|
|
121
|
+
else:
|
|
122
|
+
remaining += 1
|
|
123
|
+
return done, failed, remaining
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"FORGE_DONE_STATES",
|
|
128
|
+
"FORGE_FAILURE_STATES",
|
|
129
|
+
"FORGE_OBSOLETE_STATES",
|
|
130
|
+
"FORGE_RUNNING_STATES",
|
|
131
|
+
"FORGE_TERMINAL_STATES",
|
|
132
|
+
"forge_status_bucket",
|
|
133
|
+
"forge_status_is_terminal",
|
|
134
|
+
"forge_status_glyph",
|
|
135
|
+
"forge_status_counts",
|
|
136
|
+
]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Markdown → prompt_toolkit fragment rows for the TUI transcript.
|
|
2
|
+
|
|
3
|
+
A *completed* assistant reply is rendered through Rich's ``Markdown`` (the very
|
|
4
|
+
renderer the classic CLI uses, so the two agree on how a reply looks) into ANSI,
|
|
5
|
+
then converted to prompt_toolkit ``(style, text)`` rows. Streaming/partial text
|
|
6
|
+
and plain prose skip this entirely and render as plain lines, so a half-open
|
|
7
|
+
code fence never flashes mid-stream and a one-line answer is not reflowed.
|
|
8
|
+
|
|
9
|
+
Kept free of any agent imports (Rich + prompt_toolkit only) so it unit-tests in
|
|
10
|
+
isolation, and every public entry point is fail-safe: on any error it returns
|
|
11
|
+
``None`` and the caller falls back to plain rendering.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from io import StringIO
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from pygments.style import Style as PygmentsStyle
|
|
22
|
+
from pygments.token import Comment, Keyword, Name, Number, Operator, String
|
|
23
|
+
|
|
24
|
+
from ...surface.styles import TerminalTheme
|
|
25
|
+
|
|
26
|
+
# One visual line: a list of ``(style, text)`` fragments.
|
|
27
|
+
Row = list[tuple[str, str]]
|
|
28
|
+
|
|
29
|
+
# Matches the classic CLI's heuristic (rich_surface._looks_like_markdown) so the
|
|
30
|
+
# TUI and the plain console agree on what counts as markdown worth rendering.
|
|
31
|
+
_NUMBERED = re.compile(r"\d+\.\s")
|
|
32
|
+
_FENCED_CODE_RE = re.compile(r"```[^\n]*\n(.*?)(?:\n```|$)", re.S)
|
|
33
|
+
_CODE_FALLBACK_STYLE = "class:markdown.code"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _NeutralCodeStyle(PygmentsStyle):
|
|
37
|
+
"""Syntax colours that preserve the terminal's own background."""
|
|
38
|
+
|
|
39
|
+
background_color = None
|
|
40
|
+
highlight_color = None
|
|
41
|
+
styles = {
|
|
42
|
+
Comment: "italic ansibrightblack",
|
|
43
|
+
Keyword: "bold ansimagenta",
|
|
44
|
+
Name.Builtin: "ansicyan",
|
|
45
|
+
Number: "ansicyan",
|
|
46
|
+
Operator: "bold",
|
|
47
|
+
String: "ansigreen",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _code_theme(theme: TerminalTheme) -> Any:
|
|
52
|
+
if theme == "dark":
|
|
53
|
+
return "monokai"
|
|
54
|
+
if theme == "light":
|
|
55
|
+
return "friendly"
|
|
56
|
+
return _NeutralCodeStyle
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Rich wraps markdown links (e.g. "[apnews.com](https://…)") in an OSC 8 terminal
|
|
60
|
+
# hyperlink — ``ESC]8;id=<n>;<url>ESC\ <anchor> ESC]8;;ESC\`` — whenever
|
|
61
|
+
# legacy_windows is off, which _render_ansi deliberately sets so Rich emits ANSI
|
|
62
|
+
# rather than Win32 console calls. prompt_toolkit's ANSI parser does not
|
|
63
|
+
# understand OSC: it drops the ESC/ST control bytes but leaks the payload
|
|
64
|
+
# ("8;id=1234;https://…" and "8;;") as visible text in the transcript (the stray
|
|
65
|
+
# line users see after a web-search answer). OSC hyperlinks are not clickable
|
|
66
|
+
# inside the alt-screen TUI anyway, so strip every OSC sequence; the anchor text
|
|
67
|
+
# lives *between* the open and close markers and is left intact.
|
|
68
|
+
_OSC_SEQUENCE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def looks_like_markdown(text: str) -> bool:
|
|
72
|
+
"""True when ``text`` carries block-level markdown worth rendering.
|
|
73
|
+
|
|
74
|
+
Deliberately conservative: inline-only emphasis in a single paragraph is
|
|
75
|
+
left as plain text (rendering it would reflow newlines for no real gain).
|
|
76
|
+
"""
|
|
77
|
+
clean = str(text or "")
|
|
78
|
+
if not clean.strip():
|
|
79
|
+
return False
|
|
80
|
+
if "```" in clean:
|
|
81
|
+
return True
|
|
82
|
+
has_multiple_lines = "\n" in clean
|
|
83
|
+
for line in clean.splitlines():
|
|
84
|
+
stripped = line.lstrip()
|
|
85
|
+
if not stripped:
|
|
86
|
+
continue
|
|
87
|
+
if stripped.startswith(("#", ">", "-", "*")):
|
|
88
|
+
return True
|
|
89
|
+
if _NUMBERED.match(stripped):
|
|
90
|
+
return True
|
|
91
|
+
if has_multiple_lines and stripped.count("|") >= 2:
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@lru_cache(maxsize=256)
|
|
97
|
+
def _render_ansi(text: str, width: int, theme: TerminalTheme) -> str:
|
|
98
|
+
"""Render markdown to ANSI at a fixed width and terminal theme.
|
|
99
|
+
|
|
100
|
+
Completed replies never change, so a redraw reuses the cached ANSI; only a
|
|
101
|
+
terminal resize or theme change forces a re-render.
|
|
102
|
+
"""
|
|
103
|
+
from rich.markdown import Markdown
|
|
104
|
+
|
|
105
|
+
from ...surface.console import make_console
|
|
106
|
+
|
|
107
|
+
buf = StringIO()
|
|
108
|
+
console = make_console(
|
|
109
|
+
file=buf,
|
|
110
|
+
width=max(8, int(width)),
|
|
111
|
+
force_terminal=True,
|
|
112
|
+
color_system="truecolor",
|
|
113
|
+
highlight=False, # no repr-highlighting of numbers/strings in prose
|
|
114
|
+
emoji=False, # ":)" etc. stay literal
|
|
115
|
+
legacy_windows=False, # emit ANSI escapes, not Win32 console calls
|
|
116
|
+
)
|
|
117
|
+
console.print(Markdown(text, code_theme=_code_theme(theme))) # type: ignore[arg-type]
|
|
118
|
+
return _strip_osc_sequences(buf.getvalue())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _strip_osc_sequences(ansi: str) -> str:
|
|
122
|
+
"""Drop OSC sequences (notably Rich's OSC 8 hyperlinks) prompt_toolkit leaks.
|
|
123
|
+
|
|
124
|
+
Only the escape wrappers are removed; a hyperlink's anchor text sits between
|
|
125
|
+
its open and close markers and survives, and CSI/SGR color sequences are left
|
|
126
|
+
untouched. See ``_OSC_SEQUENCE_RE`` for why this is needed.
|
|
127
|
+
"""
|
|
128
|
+
return _OSC_SEQUENCE_RE.sub("", ansi)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_blank(row: Row) -> bool:
|
|
132
|
+
return not "".join(text for _style, text in row).strip()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _trim_row_right(row: Row) -> Row:
|
|
136
|
+
trimmed = list(row)
|
|
137
|
+
while trimmed:
|
|
138
|
+
style, text = trimmed[-1]
|
|
139
|
+
right_stripped = text.rstrip()
|
|
140
|
+
if right_stripped:
|
|
141
|
+
if right_stripped != text:
|
|
142
|
+
trimmed[-1] = (style, right_stripped)
|
|
143
|
+
break
|
|
144
|
+
trimmed.pop()
|
|
145
|
+
return trimmed
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _split_row_to_width(row: Row, width: int) -> list[Row]:
|
|
149
|
+
safe_width = max(1, int(width))
|
|
150
|
+
rows: list[Row] = []
|
|
151
|
+
current: Row = []
|
|
152
|
+
current_len = 0
|
|
153
|
+
for style, text in row:
|
|
154
|
+
remaining = str(text)
|
|
155
|
+
while remaining:
|
|
156
|
+
available = safe_width - current_len
|
|
157
|
+
if available <= 0:
|
|
158
|
+
rows.append(_trim_row_right(current))
|
|
159
|
+
current = []
|
|
160
|
+
current_len = 0
|
|
161
|
+
available = safe_width
|
|
162
|
+
chunk = remaining[:available]
|
|
163
|
+
current.append((style, chunk))
|
|
164
|
+
current_len += len(chunk)
|
|
165
|
+
remaining = remaining[available:]
|
|
166
|
+
rows.append(_trim_row_right(current))
|
|
167
|
+
return [item for item in rows if item]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fenced_code_line_keys(text: str) -> set[str]:
|
|
171
|
+
keys: set[str] = set()
|
|
172
|
+
for match in _FENCED_CODE_RE.finditer(str(text or "")):
|
|
173
|
+
for line in match.group(1).splitlines():
|
|
174
|
+
stripped = line.strip()
|
|
175
|
+
if stripped:
|
|
176
|
+
keys.add(stripped)
|
|
177
|
+
return keys
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _apply_code_fallback_styles(rows: list[Row], text: str) -> list[Row]:
|
|
181
|
+
code_line_keys = _fenced_code_line_keys(text)
|
|
182
|
+
if not code_line_keys:
|
|
183
|
+
return rows
|
|
184
|
+
styled_rows: list[Row] = []
|
|
185
|
+
for row in rows:
|
|
186
|
+
row_text = "".join(fragment_text for _style, fragment_text in row).strip()
|
|
187
|
+
if row_text in code_line_keys and not any(style for style, _text in row):
|
|
188
|
+
styled_rows.append(
|
|
189
|
+
[
|
|
190
|
+
(_CODE_FALLBACK_STYLE if fragment_text else style, fragment_text)
|
|
191
|
+
for style, fragment_text in row
|
|
192
|
+
]
|
|
193
|
+
)
|
|
194
|
+
continue
|
|
195
|
+
styled_rows.append(row)
|
|
196
|
+
return styled_rows
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@lru_cache(maxsize=256)
|
|
200
|
+
def render_markdown_rows(
|
|
201
|
+
text: str,
|
|
202
|
+
width: int,
|
|
203
|
+
theme: TerminalTheme = "neutral",
|
|
204
|
+
) -> list[Row] | None:
|
|
205
|
+
"""Markdown-render ``text`` into rows of fragments, or ``None`` to render plain.
|
|
206
|
+
|
|
207
|
+
Returns ``None`` when the text is not markdown (so the caller keeps its plain
|
|
208
|
+
layout) or when rendering fails for any reason. Never raises. ``width`` is the
|
|
209
|
+
target content width in columns.
|
|
210
|
+
|
|
211
|
+
Memoized per ``(text, width, theme)`` — the transcript re-renders EVERY completed
|
|
212
|
+
reply on every redraw, and a streaming turn redraws many times a second, so
|
|
213
|
+
without this the full ANSI→fragments→wrap post-processing re-ran each frame
|
|
214
|
+
for each finished reply (cost grows with conversation length → visible lag).
|
|
215
|
+
Caching the ``_render_ansi`` string alone was not enough. The returned rows
|
|
216
|
+
are shared across frames, so callers MUST treat them as read-only (both
|
|
217
|
+
current callers build fresh lists rather than mutating in place).
|
|
218
|
+
"""
|
|
219
|
+
if not looks_like_markdown(text):
|
|
220
|
+
return None
|
|
221
|
+
try:
|
|
222
|
+
from prompt_toolkit.formatted_text import ANSI, to_formatted_text
|
|
223
|
+
from prompt_toolkit.formatted_text.utils import split_lines
|
|
224
|
+
|
|
225
|
+
ansi = _render_ansi(text, int(width), theme)
|
|
226
|
+
fragments = to_formatted_text(ANSI(ansi.rstrip("\n")))
|
|
227
|
+
rows = []
|
|
228
|
+
for line in split_lines(fragments):
|
|
229
|
+
trimmed = _trim_row_right(list(line))
|
|
230
|
+
if not trimmed:
|
|
231
|
+
rows.append(trimmed)
|
|
232
|
+
continue
|
|
233
|
+
rows.extend(_split_row_to_width(trimmed, int(width)))
|
|
234
|
+
except Exception:
|
|
235
|
+
return None
|
|
236
|
+
# Trim trailing blank rows Rich pads on (the leading ones, if any, are kept so
|
|
237
|
+
# the caller can drop its accent marker on the first non-blank row).
|
|
238
|
+
while rows and _is_blank(rows[-1]):
|
|
239
|
+
rows.pop()
|
|
240
|
+
rows = _apply_code_fallback_styles(rows, text)
|
|
241
|
+
return rows or None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
__all__ = ["render_markdown_rows", "looks_like_markdown", "Row"]
|