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,4551 @@
|
|
|
1
|
+
"""The full-screen TUI shell + agent host.
|
|
2
|
+
|
|
3
|
+
A prompt_toolkit ``Application`` (alt-screen) that reproduces the launch
|
|
4
|
+
screenshot: a centered white owl animation, the "What can I do for you?"
|
|
5
|
+
heading, a dim hint line, a larger bordered multi-line input box, and a pinned
|
|
6
|
+
2-line footer (brand · model · context/tokens/cost; persona · execution mode ·
|
|
7
|
+
user · workspace · branch). Enter submits, Ctrl+J / Alt+Enter insert a newline,
|
|
8
|
+
Tab on an empty input cycles the persona, and Shift+Tab cycles the execution
|
|
9
|
+
mode (read → safe → fast → full) via the ``mode_cycle`` callback — the mode is
|
|
10
|
+
the sole approval authority, so the key mutates the session rather than a
|
|
11
|
+
display-only flag.
|
|
12
|
+
|
|
13
|
+
Phase 2 wires the agent: when a ``session_builder`` is supplied, the welcome body
|
|
14
|
+
swaps for a scrollable transcript and each submission runs ``session.run_turn``
|
|
15
|
+
on a worker thread. A :class:`TuiSurface` streams the agent's tokens, tool trace
|
|
16
|
+
and errors into the transcript; Ctrl+C interrupts the running turn (and exits
|
|
17
|
+
when idle). Without a ``session_builder`` the shell keeps the Phase 1 stub reply.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import textwrap
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from prompt_toolkit.application import Application
|
|
31
|
+
from prompt_toolkit.application.current import get_app
|
|
32
|
+
from prompt_toolkit.cursor_shapes import CursorShape
|
|
33
|
+
from prompt_toolkit.data_structures import Point
|
|
34
|
+
from prompt_toolkit.filters import Condition, has_focus
|
|
35
|
+
from prompt_toolkit.formatted_text import FormattedText, fragment_list_to_text, to_formatted_text
|
|
36
|
+
from prompt_toolkit.input import create_input
|
|
37
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
38
|
+
from prompt_toolkit.layout import Layout, WindowAlign
|
|
39
|
+
from prompt_toolkit.layout.containers import (
|
|
40
|
+
ConditionalContainer,
|
|
41
|
+
Float,
|
|
42
|
+
FloatContainer,
|
|
43
|
+
HSplit,
|
|
44
|
+
ScrollOffsets,
|
|
45
|
+
VSplit,
|
|
46
|
+
Window,
|
|
47
|
+
)
|
|
48
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
49
|
+
from prompt_toolkit.layout.dimension import D
|
|
50
|
+
from prompt_toolkit.layout.margins import ScrollbarMargin
|
|
51
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
52
|
+
from prompt_toolkit.layout.processors import BeforeInput, Processor, Transformation
|
|
53
|
+
from prompt_toolkit.mouse_events import MouseButton, MouseEventType
|
|
54
|
+
from prompt_toolkit.styles import Style, merge_styles
|
|
55
|
+
from prompt_toolkit.widgets import Frame, TextArea
|
|
56
|
+
|
|
57
|
+
from ...agent.steering import MAX_PENDING_STEER_MESSAGES, steer_inbox_for
|
|
58
|
+
from ...branding import env_get
|
|
59
|
+
from ...clipboard import ClipboardError, copy_text_to_clipboard
|
|
60
|
+
from ...llm.types import LLMError
|
|
61
|
+
from ...llm_error_display import friendly_llm_error_message, is_network_or_model_error
|
|
62
|
+
from ...subagent_labels import subagent_identity
|
|
63
|
+
from ...surface.styles import TerminalTheme
|
|
64
|
+
from ...surface.theme import detect_terminal_theme
|
|
65
|
+
from ...surface.types import ApprovalDecision, SubagentStartEvent
|
|
66
|
+
from ..chat.mid_turn_policy import (
|
|
67
|
+
EXIT_WORDS,
|
|
68
|
+
MidTurnAction,
|
|
69
|
+
block_message,
|
|
70
|
+
classify_mid_turn,
|
|
71
|
+
defer_message,
|
|
72
|
+
)
|
|
73
|
+
from . import content as _content
|
|
74
|
+
from .footer import footer_fragments
|
|
75
|
+
from .forge_status import (
|
|
76
|
+
forge_status_bucket,
|
|
77
|
+
forge_status_counts,
|
|
78
|
+
forge_status_glyph,
|
|
79
|
+
)
|
|
80
|
+
from .markdown import render_markdown_rows
|
|
81
|
+
from .owl import load_owl_animation
|
|
82
|
+
from .plan_meta import (
|
|
83
|
+
PLAN_META_ROW_ROLES,
|
|
84
|
+
plan_meta_rows_with_kinds,
|
|
85
|
+
strip_plan_meta_copy_chrome,
|
|
86
|
+
)
|
|
87
|
+
from .plan_meta import plan_meta_rows as render_plan_meta_rows
|
|
88
|
+
from .state import TuiState
|
|
89
|
+
from .subagent_panel import (
|
|
90
|
+
append_bounded_entries,
|
|
91
|
+
quiet_time_label,
|
|
92
|
+
subagent_panel_rows,
|
|
93
|
+
)
|
|
94
|
+
from .surface import TuiSurface, set_active_cancellation
|
|
95
|
+
from .transcript import TuiTranscript
|
|
96
|
+
|
|
97
|
+
_EXIT_WORDS = EXIT_WORDS
|
|
98
|
+
_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
99
|
+
_MAX_PENDING_COMMANDS = MAX_PENDING_STEER_MESSAGES
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ConfigReloadOutcome(Enum):
|
|
103
|
+
"""Result of applying a saved configuration to the live TUI session."""
|
|
104
|
+
|
|
105
|
+
APPLIED = "applied"
|
|
106
|
+
FAILED = "failed"
|
|
107
|
+
RESTART = "restart"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _DeferredOperationKind(Enum):
|
|
111
|
+
COMMAND = "command"
|
|
112
|
+
CONFIG_RELOAD = "config_reload"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class _DeferredOperation:
|
|
117
|
+
kind: _DeferredOperationKind
|
|
118
|
+
text: str = ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _DispatchOutcome(Enum):
|
|
122
|
+
CONTINUE = "continue"
|
|
123
|
+
EXIT = "exit"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pending_command_count(pending: list[_DeferredOperation]) -> int:
|
|
127
|
+
return sum(operation.kind is _DeferredOperationKind.COMMAND for operation in pending)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _stage_pending_command(pending: list[_DeferredOperation], text: str) -> bool:
|
|
131
|
+
"""Append a command without losing chronology or independent mutations."""
|
|
132
|
+
raw = str(text or "")
|
|
133
|
+
stripped = raw.strip()
|
|
134
|
+
if not stripped:
|
|
135
|
+
return False
|
|
136
|
+
if _pending_command_count(pending) >= _MAX_PENDING_COMMANDS:
|
|
137
|
+
return False
|
|
138
|
+
pending.append(_DeferredOperation(_DeferredOperationKind.COMMAND, raw))
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _stage_pending_config_reload(pending: list[_DeferredOperation]) -> None:
|
|
143
|
+
"""Stage a reload at its chronological position, coalescing adjacent saves."""
|
|
144
|
+
if pending and pending[-1].kind is _DeferredOperationKind.CONFIG_RELOAD:
|
|
145
|
+
return
|
|
146
|
+
pending.append(_DeferredOperation(_DeferredOperationKind.CONFIG_RELOAD))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _model_access_setup_hint(subscription_provider_id: str | None) -> str:
|
|
150
|
+
if subscription_provider_id:
|
|
151
|
+
return "Set up model access: /login to choose a connection · /config for an API key"
|
|
152
|
+
return "Set up model access in /config"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Single accent colour for the input frame, the "> " prompt, the user band and
|
|
156
|
+
# the assistant marker — a green shade. ``_BAND_BG`` is the subtle full-width
|
|
157
|
+
# highlight behind the user's own message.
|
|
158
|
+
_ACCENT = "#3fb950"
|
|
159
|
+
_BAND_BG = "#21262d"
|
|
160
|
+
# Marker drawn in front of every assistant reply so it reads as the agent's.
|
|
161
|
+
_ASSIST_MARK = "✦"
|
|
162
|
+
# The model's "thinking" (reasoning) aside, tucked just under the question:
|
|
163
|
+
# a chevron header (``▾`` open / ``▸`` collapsed) above a dim left rail ``│``.
|
|
164
|
+
_THINK_OPEN = "▾"
|
|
165
|
+
_THINK_CLOSED = "▸"
|
|
166
|
+
_THINK_RAIL = "│"
|
|
167
|
+
|
|
168
|
+
# Explicit transcript copies are semantic, not a dump of rendered terminal
|
|
169
|
+
# cells. Provider reasoning summaries and agent/tool chrome stay visible in the
|
|
170
|
+
# pane but never enter the clipboard; unknown future roles fail closed until
|
|
171
|
+
# they are intentionally classified here.
|
|
172
|
+
_COPYABLE_TRANSCRIPT_ROLES = frozenset(
|
|
173
|
+
{"user", "assistant", "error", "warn", "info", "system", "spacer", "planmeta", "planmetacont"}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
_COMPLETION_MENU_BOTTOM = 8
|
|
177
|
+
_COMPLETION_MENU_MAX_HEIGHT = 8
|
|
178
|
+
_COMPLETION_MENU_MAX_WIDTH = 84
|
|
179
|
+
_SUBAGENT_PANEL_HEIGHT = 10
|
|
180
|
+
_SUBAGENT_PANEL_CONTENT_HEIGHT = _SUBAGENT_PANEL_HEIGHT - 2
|
|
181
|
+
|
|
182
|
+
_STYLE = Style.from_dict(
|
|
183
|
+
{
|
|
184
|
+
"tui.heading": "bold",
|
|
185
|
+
"tui.credit": "#7a7a7a",
|
|
186
|
+
"tui.hint": "italic #8a8a8a",
|
|
187
|
+
"tui.placeholder": "#6c6c6c",
|
|
188
|
+
"tui.input": "",
|
|
189
|
+
"tui.prompt": f"bold {_ACCENT}",
|
|
190
|
+
# Accent-coloured border highlights the input box.
|
|
191
|
+
"frame.border": _ACCENT,
|
|
192
|
+
"tui.footer.mark": "#56b6c2",
|
|
193
|
+
"tui.footer.brand": "bold",
|
|
194
|
+
"tui.footer.model": "#d0d0d0",
|
|
195
|
+
"tui.footer.value": "#8a8a8a",
|
|
196
|
+
"tui.footer.dim": "#6c6c6c",
|
|
197
|
+
"tui.footer.user": "#8a8a8a",
|
|
198
|
+
"tui.footer.workspace": "#8a8a8a",
|
|
199
|
+
"tui.footer.branch": "#56b6c2",
|
|
200
|
+
"tui.footer.context": "#8a8a8a",
|
|
201
|
+
# Forge-session badge: a distinct bold violet chip so the planning mode is
|
|
202
|
+
# unmistakable (green stays the chat accent, cyan the brand/branch).
|
|
203
|
+
"tui.footer.forge": "bold #bc8cff",
|
|
204
|
+
# Active-subagent badge base style; the footer overlays each agent's own
|
|
205
|
+
# accent (see tui.subagent_identity), so blue is only the fallback
|
|
206
|
+
# (distinct from green chat accent, violet forge, and cyan brand).
|
|
207
|
+
"tui.footer.subagent": "bold #58a6ff",
|
|
208
|
+
# User's own message: a full-width highlighted band (the "> " in accent).
|
|
209
|
+
"tui.transcript.userband": f"bold bg:{_BAND_BG}",
|
|
210
|
+
"tui.transcript.userprompt": f"bold {_ACCENT} bg:{_BAND_BG}",
|
|
211
|
+
# Assistant reply: plain text behind an accent marker.
|
|
212
|
+
"tui.transcript.assistantmark": f"bold {_ACCENT}",
|
|
213
|
+
"tui.transcript.assistant": "",
|
|
214
|
+
# Model reasoning ("thinking"): muted + italic so it reads as background
|
|
215
|
+
# process, not the answer.
|
|
216
|
+
"tui.transcript.reasoning": "italic #8b949e",
|
|
217
|
+
"tui.transcript.reasoningmark": "#6e7681",
|
|
218
|
+
"tui.transcript.system": "#8a8a8a",
|
|
219
|
+
"tui.transcript.trace": "#6c6c6c",
|
|
220
|
+
"tui.transcript.error": "#e06c75",
|
|
221
|
+
"tui.transcript.warn": "#d19a66",
|
|
222
|
+
"tui.transcript.selection": "reverse",
|
|
223
|
+
# Subagent identity line ("↪ <name> · <mode>") — the shared subagent
|
|
224
|
+
# blue (per-agent accents live in the footer badge).
|
|
225
|
+
"tui.transcript.subagent": "bold #58a6ff",
|
|
226
|
+
"tui.status": "#8a8a8a",
|
|
227
|
+
# Working line turns amber once a turn has run long (>=30s).
|
|
228
|
+
"tui.status.warn": "#d29922",
|
|
229
|
+
# Right-hand scrollbar. The margin paints spaces, so the colour must be a
|
|
230
|
+
# BACKGROUND (a foreground does nothing on a blank cell, leaving the ugly
|
|
231
|
+
# default light-grey bar). Faint near-black track + accent-green thumb.
|
|
232
|
+
"scrollbar.background": "bg:#1a1a1a",
|
|
233
|
+
"scrollbar.button": f"bg:{_ACCENT}",
|
|
234
|
+
"scrollbar.arrow": f"{_ACCENT} bg:#1a1a1a",
|
|
235
|
+
"scrollbar.start": "nounderline",
|
|
236
|
+
"scrollbar.end": "nounderline",
|
|
237
|
+
# Centered /help popup: an opaque dark panel (so the transcript behind it
|
|
238
|
+
# does not bleed through), green commands on the left, dim descriptions on
|
|
239
|
+
# the right, amber section headers, a dim footer hint.
|
|
240
|
+
"tui.help": "bg:#0d1117",
|
|
241
|
+
"tui.help.cmd": f"bold {_ACCENT} bg:#0d1117",
|
|
242
|
+
"tui.help.desc": "#c9d1d9 bg:#0d1117",
|
|
243
|
+
# Section headers: bold + bright neutral (no colour clash with the green
|
|
244
|
+
# commands) so the green stays the only accent.
|
|
245
|
+
"tui.help.section": "bold #e6edf3 bg:#0d1117",
|
|
246
|
+
"tui.help.hint": "italic #6e7681 bg:#0d1117",
|
|
247
|
+
"tui.help.frame": "bg:#0d1117",
|
|
248
|
+
"tui.help.frame frame.border": _ACCENT,
|
|
249
|
+
# Key/value panels (e.g. /status) reuse the same opaque /help chrome. Keys
|
|
250
|
+
# read as dim labels; values are toned by health: accent green = on/healthy,
|
|
251
|
+
# plain = neutral/degraded, amber = caution, red = error. Green stays the
|
|
252
|
+
# single accent — degraded states are plain, never a second accent.
|
|
253
|
+
"tui.help.key": "#8a8a8a bg:#0d1117",
|
|
254
|
+
"tui.help.accent": f"bold {_ACCENT} bg:#0d1117",
|
|
255
|
+
"tui.help.warn": "#d19a66 bg:#0d1117",
|
|
256
|
+
"tui.help.err": "#e06c75 bg:#0d1117",
|
|
257
|
+
# Slash-command dropdown (shown while typing "/…"): same dark panel as the
|
|
258
|
+
# popups; the highlighted row uses the band bg + accent green so it reads
|
|
259
|
+
# like the panels. Green stays the only accent.
|
|
260
|
+
"completion-menu": "bg:#0d1117",
|
|
261
|
+
"completion-menu.completion": "bg:#0d1117 #c9d1d9",
|
|
262
|
+
"completion-menu.completion.current": f"bg:{_BAND_BG} bold {_ACCENT}",
|
|
263
|
+
"completion-menu.meta.completion": "bg:#0d1117 #6e7681",
|
|
264
|
+
"completion-menu.meta.completion.current": f"bg:{_BAND_BG} #c9d1d9",
|
|
265
|
+
# Selectable picker (e.g. /mode): same dark panel; the focused row gets the
|
|
266
|
+
# band bg + accent caret/label so it stands out. Green is the only accent.
|
|
267
|
+
"tui.picker": "bg:#0d1117",
|
|
268
|
+
"tui.picker.num": "#8a8a8a bg:#0d1117",
|
|
269
|
+
"tui.picker.label": "#c9d1d9 bg:#0d1117",
|
|
270
|
+
"tui.picker.desc": "#8a8a8a bg:#0d1117",
|
|
271
|
+
"tui.picker.warndesc": "#d29922 bg:#0d1117",
|
|
272
|
+
"tui.picker.header": "bold #6e7681 bg:#0d1117",
|
|
273
|
+
"tui.picker.tag": f"{_ACCENT} bg:#0d1117",
|
|
274
|
+
"tui.picker.hint": "italic #6e7681 bg:#0d1117",
|
|
275
|
+
"tui.picker.sel": f"bg:{_BAND_BG}",
|
|
276
|
+
"tui.picker.selcaret": f"bold {_ACCENT} bg:{_BAND_BG}",
|
|
277
|
+
"tui.picker.selnum": f"bold {_ACCENT} bg:{_BAND_BG}",
|
|
278
|
+
"tui.picker.sellabel": f"bold {_ACCENT} bg:{_BAND_BG}",
|
|
279
|
+
"tui.picker.seldesc": f"#c9d1d9 bg:{_BAND_BG}",
|
|
280
|
+
"tui.picker.seltag": f"{_ACCENT} bg:{_BAND_BG}",
|
|
281
|
+
# Execution-mode badge in the footer: accent green normally, amber when in
|
|
282
|
+
# the unguarded fullaccess mode so the danger state is glanceable.
|
|
283
|
+
"tui.footer.mode": f"bold {_ACCENT}",
|
|
284
|
+
"tui.footer.mode.warn": "bold #d19a66",
|
|
285
|
+
# Live Forge execution view (the task table + phase line shown while
|
|
286
|
+
# /execute plan runs). Violet ties it to the FORGE badge; done=green,
|
|
287
|
+
# failed=red so task outcomes are glanceable.
|
|
288
|
+
"tui.forge.head": "bold #bc8cff",
|
|
289
|
+
"tui.forge.rule": "#6e7681",
|
|
290
|
+
"tui.forge.done": f"bold {_ACCENT}",
|
|
291
|
+
"tui.forge.fail": "bold #e06c75",
|
|
292
|
+
"tui.forge.run": "bold #bc8cff",
|
|
293
|
+
"tui.forge.idle": "#6c6c6c",
|
|
294
|
+
"tui.forge.id": "#8a8a8a",
|
|
295
|
+
"tui.forge.title": "#c9d1d9",
|
|
296
|
+
# Live header progress gauge: X/Y count (violet, ties to the header), a
|
|
297
|
+
# violet "done" bar segment, a red "failed" segment, and a dim empty
|
|
298
|
+
# track — so run progress is glanceable without reading every row.
|
|
299
|
+
"tui.forge.count": "bold #bc8cff",
|
|
300
|
+
"tui.forge.meter": "#bc8cff",
|
|
301
|
+
"tui.forge.meterfail": "#e06c75",
|
|
302
|
+
"tui.forge.meterempty": "#3a3a3a",
|
|
303
|
+
# Per-task elapsed timer on a running row (dim so it never competes with
|
|
304
|
+
# the title).
|
|
305
|
+
"tui.forge.timer": "#6e7681",
|
|
306
|
+
# Forge planner plan-update aside ("▸ plan updated · N notes"): violet
|
|
307
|
+
# chevrons/glyphs tie it to the Forge identity, touched-task titles read
|
|
308
|
+
# bright like the forge table, planner warnings get an amber ⚠, and the
|
|
309
|
+
# rest stays dim so the aside never competes with the reply above it.
|
|
310
|
+
"tui.planmeta.mark": "#bc8cff",
|
|
311
|
+
"tui.planmeta.head": "#8b949e",
|
|
312
|
+
"tui.planmeta.dim": "#6c6c6c",
|
|
313
|
+
"tui.planmeta.subject": "#c9d1d9",
|
|
314
|
+
"tui.planmeta.tag": "italic #8a8a8a",
|
|
315
|
+
"tui.planmeta.warn": "#d19a66",
|
|
316
|
+
"tui.planmeta.hint": "italic #6e7681",
|
|
317
|
+
# Live subagent tail: blue identity chrome with quiet body text. The
|
|
318
|
+
# frame sits in the root HSplit, so it takes space from the transcript.
|
|
319
|
+
"tui.subpanel": "",
|
|
320
|
+
"tui.subpanel frame.border": "#58a6ff",
|
|
321
|
+
"tui.subpanel.head": "bold #58a6ff",
|
|
322
|
+
"tui.subpanel.state": "bold #c9d1d9",
|
|
323
|
+
"tui.subpanel.dim": "#8b949e",
|
|
324
|
+
"tui.subpanel.tool": "#d19a66",
|
|
325
|
+
"tui.subpanel.result": "#8b949e",
|
|
326
|
+
"tui.subpanel.assistant": "#c9d1d9",
|
|
327
|
+
"tui.subpanel.user": "#8b949e",
|
|
328
|
+
"tui.subpanel.hint": "italic #6e7681",
|
|
329
|
+
# Violet frame border for Forge popups (/show, /plan, the launch gate),
|
|
330
|
+
# applied by wrapping the shared help frame in a container that adds the
|
|
331
|
+
# ``tui.forgeframe`` ancestor class. MUST be declared AFTER
|
|
332
|
+
# ``tui.help.frame frame.border`` above: both selectors have equal
|
|
333
|
+
# specificity, so the later declaration wins the tie and repaints the
|
|
334
|
+
# border violet only while a Forge panel is open.
|
|
335
|
+
"tui.forgeframe frame.border": "#bc8cff",
|
|
336
|
+
# In-TUI editor float (e.g. /plan edit): dark panel + a status bar that
|
|
337
|
+
# turns red when a save is rejected (invalid JSON / shape).
|
|
338
|
+
"tui.editor": "bg:#0d1117 #c9d1d9",
|
|
339
|
+
"tui.editor.statusbar": "bg:#161b22",
|
|
340
|
+
"tui.editor.hint": "italic #6e7681 bg:#161b22",
|
|
341
|
+
"tui.editor.err": "bold #e06c75 bg:#161b22",
|
|
342
|
+
# Approval modal: an amber-bordered popup (red-bordered when destructive) so
|
|
343
|
+
# the question grabs attention; colour-coded keys (y green / a cyan / n red).
|
|
344
|
+
"tui.approve": "bg:#0d1117",
|
|
345
|
+
"tui.approve.head": "bold #d19a66 bg:#0d1117",
|
|
346
|
+
"tui.approve.head.danger": "bold #e06c75 bg:#0d1117",
|
|
347
|
+
"tui.approve.target": "bold #e6edf3 bg:#0d1117",
|
|
348
|
+
"tui.approve.reason": "italic #8a8a8a bg:#0d1117",
|
|
349
|
+
"tui.approve.optlabel": "#c9d1d9 bg:#0d1117",
|
|
350
|
+
"tui.approve.key.yes": f"bold {_ACCENT} bg:#0d1117",
|
|
351
|
+
"tui.approve.key.always": "bold #56b6c2 bg:#0d1117",
|
|
352
|
+
"tui.approve.key.no": "bold #e06c75 bg:#0d1117",
|
|
353
|
+
"tui.approve.frame": "bg:#0d1117",
|
|
354
|
+
"tui.approve.frame frame.border": "#d19a66",
|
|
355
|
+
"tui.modal.scrim": "bg:#0d1117",
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
_DARK_STYLE_RULES: dict[str, str] = dict(_STYLE.style_rules)
|
|
360
|
+
|
|
361
|
+
_LIGHT_COLOR_TOKENS = {
|
|
362
|
+
"#3fb950": "#187a3d",
|
|
363
|
+
"#21262d": "#eef3ee",
|
|
364
|
+
"#7a7a7a": "#57606a",
|
|
365
|
+
"#8a8a8a": "#57606a",
|
|
366
|
+
"#6c6c6c": "#6e7781",
|
|
367
|
+
"#56b6c2": "#0969da",
|
|
368
|
+
"#d0d0d0": "#24292f",
|
|
369
|
+
"#bc8cff": "#8250df",
|
|
370
|
+
"#e3b341": "#9a6700",
|
|
371
|
+
"#58a6ff": "#0969da",
|
|
372
|
+
"#8b949e": "#57606a",
|
|
373
|
+
"#6e7681": "#57606a",
|
|
374
|
+
"#e06c75": "#cf222e",
|
|
375
|
+
"#d19a66": "#9a6700",
|
|
376
|
+
"#d29922": "#9a6700",
|
|
377
|
+
"#1a1a1a": "#d0d7de",
|
|
378
|
+
"#0d1117": "#f6f8fa",
|
|
379
|
+
"#c9d1d9": "#24292f",
|
|
380
|
+
"#e6edf3": "#1f2328",
|
|
381
|
+
"#3a3a3a": "#d8dee4",
|
|
382
|
+
"#161b22": "#eaeef2",
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
_NEUTRAL_COLOR_TOKENS = {
|
|
386
|
+
"#3fb950": "ansigreen",
|
|
387
|
+
"#21262d": "",
|
|
388
|
+
"#7a7a7a": "",
|
|
389
|
+
"#8a8a8a": "",
|
|
390
|
+
"#6c6c6c": "",
|
|
391
|
+
"#56b6c2": "ansicyan",
|
|
392
|
+
"#d0d0d0": "",
|
|
393
|
+
"#bc8cff": "ansimagenta",
|
|
394
|
+
"#e3b341": "ansiyellow",
|
|
395
|
+
"#58a6ff": "ansiblue",
|
|
396
|
+
"#8b949e": "",
|
|
397
|
+
"#6e7681": "",
|
|
398
|
+
"#e06c75": "ansired",
|
|
399
|
+
"#d19a66": "ansiyellow",
|
|
400
|
+
"#d29922": "ansiyellow",
|
|
401
|
+
"#1a1a1a": "",
|
|
402
|
+
"#0d1117": "",
|
|
403
|
+
"#c9d1d9": "",
|
|
404
|
+
"#e6edf3": "",
|
|
405
|
+
"#3a3a3a": "",
|
|
406
|
+
"#161b22": "",
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _theme_style_value(value: str, theme: TerminalTheme) -> str:
|
|
411
|
+
if theme == "dark":
|
|
412
|
+
return value
|
|
413
|
+
replacements = _LIGHT_COLOR_TOKENS if theme == "light" else _NEUTRAL_COLOR_TOKENS
|
|
414
|
+
translated: list[str] = []
|
|
415
|
+
for token in value.split():
|
|
416
|
+
if token.startswith("bg:"):
|
|
417
|
+
replacement = replacements.get(token[3:], token[3:])
|
|
418
|
+
if replacement:
|
|
419
|
+
translated.append(f"bg:{replacement}")
|
|
420
|
+
continue
|
|
421
|
+
replacement = replacements.get(token, token)
|
|
422
|
+
if replacement:
|
|
423
|
+
translated.append(replacement)
|
|
424
|
+
return " ".join(translated)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _build_tui_style(theme: TerminalTheme) -> Style:
|
|
428
|
+
"""Build chat chrome from semantic terminal-theme colours.
|
|
429
|
+
|
|
430
|
+
Unknown terminals use the neutral palette: semantic ANSI accents remain,
|
|
431
|
+
while surface backgrounds and assumed foreground grays are inherited from
|
|
432
|
+
the terminal. This avoids guessing either a dark or a light canvas.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
rules = {
|
|
436
|
+
selector: _theme_style_value(value, theme) for selector, value in _DARK_STYLE_RULES.items()
|
|
437
|
+
}
|
|
438
|
+
if theme == "light":
|
|
439
|
+
rules["tui.input"] = "#1f2328"
|
|
440
|
+
rules["tui.transcript.userband"] = "bold #24292f bg:#eef3ee"
|
|
441
|
+
elif theme == "dark":
|
|
442
|
+
rules["tui.input"] = "#e6edf3"
|
|
443
|
+
return Style.from_dict(rules)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# Stub reply used only when no agent session is wired (Phase 1 / tests).
|
|
447
|
+
_PREVIEW_REPLY = "TUI preview - no agent session attached."
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _user_band_rows(text: str, width: int) -> list[list[tuple[str, str]]]:
|
|
451
|
+
"""Render the user's own message as a full-width highlighted band (one blank
|
|
452
|
+
band row above and below the text) so their question stands out from the
|
|
453
|
+
agent's reply. Wrap-aware; every row is padded to ``width``.
|
|
454
|
+
|
|
455
|
+
Returns a list of rows, each a list of ``(style, text)`` fragments.
|
|
456
|
+
"""
|
|
457
|
+
width = max(8, int(width))
|
|
458
|
+
inner = max(1, width - 2) # room for the "› " / " " prefix
|
|
459
|
+
wrapped: list[str] = []
|
|
460
|
+
for line in text.split("\n") or [""]:
|
|
461
|
+
if line:
|
|
462
|
+
wrapped.extend(textwrap.wrap(line, inner) or [""])
|
|
463
|
+
else:
|
|
464
|
+
wrapped.append("")
|
|
465
|
+
if not wrapped:
|
|
466
|
+
wrapped = [""]
|
|
467
|
+
band = "class:tui.transcript.userband"
|
|
468
|
+
prompt = "class:tui.transcript.userprompt"
|
|
469
|
+
blank_row: list[tuple[str, str]] = [(band, " " * width)]
|
|
470
|
+
rows: list[list[tuple[str, str]]] = [blank_row]
|
|
471
|
+
for index, line in enumerate(wrapped):
|
|
472
|
+
prefix = "› " if index == 0 else " "
|
|
473
|
+
rows.append([(prompt, prefix), (band, line.ljust(width - len(prefix)))])
|
|
474
|
+
rows.append([(band, " " * width)])
|
|
475
|
+
return rows
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# Rows scrolled per mouse-wheel notch. Three matches the familiar default used
|
|
479
|
+
# by editors such as Vim, while an environment override keeps high-resolution
|
|
480
|
+
# trackpads and coarse physical wheels tunable without a persistent HUD.
|
|
481
|
+
_SCROLL_SPEED_ENV = "ALYSIS_SCROLL_SPEED"
|
|
482
|
+
_DEFAULT_WHEEL_STEP_ROWS = 3
|
|
483
|
+
_MIN_WHEEL_STEP_ROWS = 1
|
|
484
|
+
_MAX_WHEEL_STEP_ROWS = 20
|
|
485
|
+
_COPY_NOTICE_SECONDS = 1.5
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _resolve_wheel_step_rows(raw_value: str | None = None) -> int:
|
|
489
|
+
raw = env_get(_SCROLL_SPEED_ENV, "") if raw_value is None else raw_value
|
|
490
|
+
try:
|
|
491
|
+
parsed = int(str(raw or "").strip())
|
|
492
|
+
except (TypeError, ValueError):
|
|
493
|
+
return _DEFAULT_WHEEL_STEP_ROWS
|
|
494
|
+
return max(_MIN_WHEEL_STEP_ROWS, min(parsed, _MAX_WHEEL_STEP_ROWS))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _resolve_tui_input(explicit_input: Any | None) -> tuple[Any, Any | None]:
|
|
498
|
+
"""Prefer the controlling terminal when stdin is redirected through a pipe."""
|
|
499
|
+
if explicit_input is not None:
|
|
500
|
+
return explicit_input, None
|
|
501
|
+
created_input = create_input(always_prefer_tty=True)
|
|
502
|
+
return created_input, created_input
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class _ScrollableControl(FormattedTextControl):
|
|
506
|
+
"""FormattedTextControl that routes mouse-wheel events to ``on_scroll`` so the
|
|
507
|
+
wheel drives our follow/scroll state. Returning ``None`` marks the event
|
|
508
|
+
handled, stopping the Window's own ``vertical_scroll`` (which the cursor-pin
|
|
509
|
+
would otherwise override); other events fall through unchanged.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
def __init__(
|
|
513
|
+
self,
|
|
514
|
+
*args: Any,
|
|
515
|
+
on_scroll: Callable[[int], None],
|
|
516
|
+
on_mouse_event: Callable[[Any], Any] | None = None,
|
|
517
|
+
**kwargs: Any,
|
|
518
|
+
) -> None:
|
|
519
|
+
super().__init__(*args, **kwargs)
|
|
520
|
+
self._on_scroll = on_scroll
|
|
521
|
+
self._on_mouse_event = on_mouse_event
|
|
522
|
+
|
|
523
|
+
def mouse_handler(self, mouse_event: Any) -> Any:
|
|
524
|
+
event_type = mouse_event.event_type
|
|
525
|
+
if event_type == MouseEventType.SCROLL_UP:
|
|
526
|
+
self._on_scroll(-1)
|
|
527
|
+
return None
|
|
528
|
+
if event_type == MouseEventType.SCROLL_DOWN:
|
|
529
|
+
self._on_scroll(1)
|
|
530
|
+
return None
|
|
531
|
+
if self._on_mouse_event is not None:
|
|
532
|
+
return self._on_mouse_event(mouse_event)
|
|
533
|
+
return NotImplemented
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _ordered_selection(anchor: Point, active: Point) -> tuple[Point, Point]:
|
|
537
|
+
if (anchor.y, anchor.x) <= (active.y, active.x):
|
|
538
|
+
return anchor, active
|
|
539
|
+
return active, anchor
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _selection_span_for_row(
|
|
543
|
+
*,
|
|
544
|
+
row_index: int,
|
|
545
|
+
row_length: int,
|
|
546
|
+
anchor: Point,
|
|
547
|
+
active: Point,
|
|
548
|
+
) -> tuple[int, int] | None:
|
|
549
|
+
if anchor == active:
|
|
550
|
+
return None
|
|
551
|
+
start, end = _ordered_selection(anchor, active)
|
|
552
|
+
if row_index < start.y or row_index > end.y:
|
|
553
|
+
return None
|
|
554
|
+
start_col = start.x if row_index == start.y else 0
|
|
555
|
+
end_col = end.x + 1 if row_index == end.y else row_length
|
|
556
|
+
start_col = max(0, min(start_col, row_length))
|
|
557
|
+
end_col = max(start_col, min(end_col, row_length))
|
|
558
|
+
return (start_col, end_col) if end_col > start_col else None
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _selected_text(
|
|
562
|
+
rows: list[str],
|
|
563
|
+
anchor: Point,
|
|
564
|
+
active: Point,
|
|
565
|
+
*,
|
|
566
|
+
row_roles: list[str] | None = None,
|
|
567
|
+
) -> str:
|
|
568
|
+
"""Extract the selected transcript text.
|
|
569
|
+
|
|
570
|
+
``row_roles`` is supplied by the live TUI and makes clipboard copies
|
|
571
|
+
semantic: only conversation/diagnostic rows are eligible, while provider
|
|
572
|
+
reasoning summaries, tool traces, Forge activity, and other UI chrome are
|
|
573
|
+
omitted. The optional default preserves the helper's plain-row behaviour for
|
|
574
|
+
callers that do not have transcript role metadata.
|
|
575
|
+
"""
|
|
576
|
+
if not rows or anchor == active:
|
|
577
|
+
return ""
|
|
578
|
+
start, end = _ordered_selection(anchor, active)
|
|
579
|
+
first_row = max(0, min(start.y, len(rows) - 1))
|
|
580
|
+
last_row = max(first_row, min(end.y, len(rows) - 1))
|
|
581
|
+
pieces: list[str] = []
|
|
582
|
+
for row_index in range(first_row, last_row + 1):
|
|
583
|
+
row = rows[row_index]
|
|
584
|
+
role = None
|
|
585
|
+
if row_roles is not None:
|
|
586
|
+
role = row_roles[row_index] if row_index < len(row_roles) else ""
|
|
587
|
+
if role not in _COPYABLE_TRANSCRIPT_ROLES:
|
|
588
|
+
continue
|
|
589
|
+
span = _selection_span_for_row(
|
|
590
|
+
row_index=row_index,
|
|
591
|
+
row_length=len(row),
|
|
592
|
+
anchor=start,
|
|
593
|
+
active=end,
|
|
594
|
+
)
|
|
595
|
+
if span is None:
|
|
596
|
+
pieces.append("")
|
|
597
|
+
continue
|
|
598
|
+
piece = row[span[0] : span[1]].rstrip()
|
|
599
|
+
pieces.append(_strip_transcript_copy_chrome(piece, role=role, starts_row=span[0] == 0))
|
|
600
|
+
return "\n".join(pieces).strip("\n")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _strip_transcript_copy_chrome(
|
|
604
|
+
text: str, *, role: str | None = None, starts_row: bool = True
|
|
605
|
+
) -> str:
|
|
606
|
+
"""Remove Alysis Code-only prefixes from copied transcript rows."""
|
|
607
|
+
if role == "planmeta":
|
|
608
|
+
# Plan-aside lead rows: drop the chevron/bullet/⚠ glyph and the expand
|
|
609
|
+
# hint, keep the indentation (it carries the note hierarchy). A sweep
|
|
610
|
+
# that starts mid-row begins with note content, so only the hint goes.
|
|
611
|
+
return strip_plan_meta_copy_chrome(text, starts_row=starts_row)
|
|
612
|
+
if role == "planmetacont":
|
|
613
|
+
# A wrap continuation of a plan-aside note: its first character is
|
|
614
|
+
# note CONTENT (which may legitimately be '+', '~', or '·') — never
|
|
615
|
+
# strip anything from it.
|
|
616
|
+
return text
|
|
617
|
+
prefixes = (
|
|
618
|
+
"› ",
|
|
619
|
+
f"{_ASSIST_MARK} ",
|
|
620
|
+
f"{_THINK_OPEN} ",
|
|
621
|
+
f"{_THINK_CLOSED} ",
|
|
622
|
+
f"{_THINK_RAIL} ",
|
|
623
|
+
)
|
|
624
|
+
for prefix in prefixes:
|
|
625
|
+
if text.startswith(prefix):
|
|
626
|
+
return text[len(prefix) :]
|
|
627
|
+
# Failed tool outcomes remain useful diagnostics even though successful tool
|
|
628
|
+
# trace rows are excluded. Remove their status decoration without treating a
|
|
629
|
+
# literal glyph in user/assistant content as chrome.
|
|
630
|
+
if role == "error" and text.startswith("✗ "):
|
|
631
|
+
return text[2:]
|
|
632
|
+
if role == "warn" and text.startswith("⚠ "):
|
|
633
|
+
return text[2:]
|
|
634
|
+
return text
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _plan_meta_press_hit(row_roles: list[str], y: int) -> bool:
|
|
638
|
+
"""True when a mouse press at row ``y`` lands on the plan-update aside
|
|
639
|
+
(any of its rows, including wrap continuations)."""
|
|
640
|
+
return 0 <= y < len(row_roles) and row_roles[y] in PLAN_META_ROW_ROLES
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _plan_meta_click_toggles(
|
|
644
|
+
*, pressed_planmeta: bool, anchor: Point | None, release: Point, selected: str
|
|
645
|
+
) -> bool:
|
|
646
|
+
"""The press+release was a plain CLICK on the aside — the pointer never
|
|
647
|
+
moved and nothing was swept — so the release toggles it. Any drag, even
|
|
648
|
+
one whose sweep strips to empty text (e.g. out over the blank margin or
|
|
649
|
+
chrome rows), is a selection gesture and must never toggle."""
|
|
650
|
+
return bool(pressed_planmeta) and not selected and anchor == release
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _highlight_selection_in_row(
|
|
654
|
+
row: list[tuple[str, str]],
|
|
655
|
+
*,
|
|
656
|
+
row_index: int,
|
|
657
|
+
anchor: Point | None,
|
|
658
|
+
active: Point | None,
|
|
659
|
+
) -> list[tuple[str, str]]:
|
|
660
|
+
if anchor is None or active is None:
|
|
661
|
+
return row
|
|
662
|
+
row_length = sum(len(text) for _style, text in row)
|
|
663
|
+
span = _selection_span_for_row(
|
|
664
|
+
row_index=row_index,
|
|
665
|
+
row_length=row_length,
|
|
666
|
+
anchor=anchor,
|
|
667
|
+
active=active,
|
|
668
|
+
)
|
|
669
|
+
if span is None:
|
|
670
|
+
return row
|
|
671
|
+
selection_start, selection_end = span
|
|
672
|
+
highlighted: list[tuple[str, str]] = []
|
|
673
|
+
cursor = 0
|
|
674
|
+
for style, text in row:
|
|
675
|
+
fragment_start = cursor
|
|
676
|
+
fragment_end = cursor + len(text)
|
|
677
|
+
overlap_start = max(fragment_start, selection_start)
|
|
678
|
+
overlap_end = min(fragment_end, selection_end)
|
|
679
|
+
if overlap_start >= overlap_end:
|
|
680
|
+
highlighted.append((style, text))
|
|
681
|
+
else:
|
|
682
|
+
before = text[: overlap_start - fragment_start]
|
|
683
|
+
selected = text[overlap_start - fragment_start : overlap_end - fragment_start]
|
|
684
|
+
after = text[overlap_end - fragment_start :]
|
|
685
|
+
if before:
|
|
686
|
+
highlighted.append((style, before))
|
|
687
|
+
highlighted.append((f"{style} class:tui.transcript.selection".strip(), selected))
|
|
688
|
+
if after:
|
|
689
|
+
highlighted.append((style, after))
|
|
690
|
+
cursor = fragment_end
|
|
691
|
+
return highlighted
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _copy_selection_notice(selected: str) -> str:
|
|
695
|
+
"""Copy a completed selection and return the transient status message."""
|
|
696
|
+
try:
|
|
697
|
+
copy_text_to_clipboard(selected)
|
|
698
|
+
except ClipboardError:
|
|
699
|
+
return "Selected text · clipboard unavailable"
|
|
700
|
+
unit = "character" if len(selected) == 1 else "characters"
|
|
701
|
+
return f"Copied {len(selected):,} {unit}"
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _scroll_target(current_row: int, last_row: int, delta: int) -> tuple[int, bool]:
|
|
705
|
+
"""Clamp a scroll move and report whether we landed at the live tail.
|
|
706
|
+
|
|
707
|
+
Returns ``(new_row, follow)`` — ``follow`` is True when ``new_row`` reaches the
|
|
708
|
+
last row, so new content keeps auto-scrolling.
|
|
709
|
+
"""
|
|
710
|
+
last = max(0, last_row)
|
|
711
|
+
target = max(0, min(current_row + delta, last))
|
|
712
|
+
return target, target >= last
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _wrap_line(line: str, width: int) -> list[str]:
|
|
716
|
+
"""Wrap one logical line to ``<= width`` columns, preserving blank lines.
|
|
717
|
+
|
|
718
|
+
The transcript window renders with ``wrap_lines=True``, so any emitted row
|
|
719
|
+
WIDER than the content width is silently re-wrapped into extra *screen* rows
|
|
720
|
+
that the follow/scroll math (which counts logical rows) never accounts for.
|
|
721
|
+
That undercount makes auto-follow undershoot, pushing the live "thinking"
|
|
722
|
+
line below the fold so it reads as hidden behind the footer. Pre-wrapping
|
|
723
|
+
every emitted row keeps logical rows == screen rows so follow lands on the
|
|
724
|
+
true last line. ``break_long_words`` guarantees even a single long token
|
|
725
|
+
(e.g. a URL) is hard-broken to ``<= width``.
|
|
726
|
+
"""
|
|
727
|
+
width = max(1, int(width))
|
|
728
|
+
if not line:
|
|
729
|
+
return [""]
|
|
730
|
+
return textwrap.wrap(line, width, break_long_words=True, break_on_hyphens=False) or [""]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _plain_role_rows(style: str, text: str, width: int) -> list[list[tuple[str, str]]]:
|
|
734
|
+
"""Render a non-streamed line (trace/error/warn/system/info) wrapped to width."""
|
|
735
|
+
rows: list[list[tuple[str, str]]] = []
|
|
736
|
+
for sub in text.split("\n") or [""]:
|
|
737
|
+
for chunk in _wrap_line(sub, width):
|
|
738
|
+
rows.append([(style, chunk)])
|
|
739
|
+
return rows
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _completion_menu_height(
|
|
743
|
+
rows: int,
|
|
744
|
+
*,
|
|
745
|
+
bottom_margin: int = _COMPLETION_MENU_BOTTOM,
|
|
746
|
+
max_height: int = _COMPLETION_MENU_MAX_HEIGHT,
|
|
747
|
+
) -> int:
|
|
748
|
+
"""Cap the slash menu so it stays above the input/footer chrome."""
|
|
749
|
+
available = max(1, int(rows) - int(bottom_margin) - 2)
|
|
750
|
+
return max(1, min(int(max_height), available))
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _completion_menu_width(
|
|
754
|
+
cols: int,
|
|
755
|
+
*,
|
|
756
|
+
max_width: int = _COMPLETION_MENU_MAX_WIDTH,
|
|
757
|
+
) -> int:
|
|
758
|
+
"""Bound completion rows so long command help cannot overflow right."""
|
|
759
|
+
return max(20, min(int(max_width), max(20, int(cols) - 8)))
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _assistant_rows(
|
|
763
|
+
text: str,
|
|
764
|
+
width: int = 80,
|
|
765
|
+
*,
|
|
766
|
+
markdown: bool = True,
|
|
767
|
+
theme: TerminalTheme = "neutral",
|
|
768
|
+
) -> list[list[tuple[str, str]]]:
|
|
769
|
+
"""Render an assistant reply behind the accent marker, with continuation
|
|
770
|
+
lines indented to align under the first line's text.
|
|
771
|
+
|
|
772
|
+
When ``markdown`` is on (the reply is complete) and the text carries
|
|
773
|
+
block-level markdown, it is rendered through Rich into styled rows — headings,
|
|
774
|
+
lists, tables and syntax-highlighted code blocks. The marker lands on the
|
|
775
|
+
first non-blank row; everything else is indented two columns to match. While
|
|
776
|
+
a reply is still streaming the caller passes ``markdown=False`` so partial
|
|
777
|
+
output (a half-open code fence) renders as plain text and never flickers.
|
|
778
|
+
"""
|
|
779
|
+
mark = "class:tui.transcript.assistantmark"
|
|
780
|
+
body = "class:tui.transcript.assistant"
|
|
781
|
+
if markdown:
|
|
782
|
+
md_rows = render_markdown_rows(text, max(1, int(width) - 2), theme)
|
|
783
|
+
if md_rows is not None:
|
|
784
|
+
marker_at = next(
|
|
785
|
+
(i for i, row in enumerate(md_rows) if "".join(t for _s, t in row).strip()),
|
|
786
|
+
0,
|
|
787
|
+
)
|
|
788
|
+
rows: list[list[tuple[str, str]]] = []
|
|
789
|
+
for index, row in enumerate(md_rows):
|
|
790
|
+
prefix = (mark, f"{_ASSIST_MARK} ") if index == marker_at else (body, " ")
|
|
791
|
+
rows.append([prefix, *row])
|
|
792
|
+
return rows
|
|
793
|
+
# Plain fallback (streaming or non-markdown). Wrap to the content width minus
|
|
794
|
+
# the 2-col marker/indent so logical rows match screen rows (see _wrap_line).
|
|
795
|
+
inner = max(1, int(width) - 2)
|
|
796
|
+
rows = []
|
|
797
|
+
first = True
|
|
798
|
+
for line in text.split("\n") or [""]:
|
|
799
|
+
for chunk in _wrap_line(line, inner):
|
|
800
|
+
if first:
|
|
801
|
+
rows.append([(mark, f"{_ASSIST_MARK} "), (body, chunk)])
|
|
802
|
+
first = False
|
|
803
|
+
else:
|
|
804
|
+
rows.append([(body, f" {chunk}")])
|
|
805
|
+
return rows
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _reasoning_rows(
|
|
809
|
+
text: str,
|
|
810
|
+
width: int,
|
|
811
|
+
*,
|
|
812
|
+
live: bool,
|
|
813
|
+
secs: int,
|
|
814
|
+
expanded: bool,
|
|
815
|
+
spinner: str = "",
|
|
816
|
+
elapsed: int = 0,
|
|
817
|
+
) -> list[list[tuple[str, str]]]:
|
|
818
|
+
"""Render a provider-generated reasoning summary as a dim aside.
|
|
819
|
+
|
|
820
|
+
A header sits flush left, directly beneath the user's message: while ``live``
|
|
821
|
+
it animates as ``⠋ thinking… Ns`` (the ``spinner`` char + ``elapsed`` seconds);
|
|
822
|
+
once closed it collapses to a short header (expanded automatically for
|
|
823
|
+
``/trace full``). Provider adapters never route raw or encrypted reasoning
|
|
824
|
+
through this renderer.
|
|
825
|
+
"""
|
|
826
|
+
rail = "class:tui.transcript.reasoningmark"
|
|
827
|
+
body = "class:tui.transcript.reasoning"
|
|
828
|
+
show_body = live or expanded
|
|
829
|
+
if live:
|
|
830
|
+
lead = spinner or _THINK_OPEN
|
|
831
|
+
header = f"reasoning summary… {elapsed}s" if elapsed else "reasoning summary…"
|
|
832
|
+
elif expanded:
|
|
833
|
+
lead = _THINK_OPEN
|
|
834
|
+
header = "reasoning summary"
|
|
835
|
+
else:
|
|
836
|
+
lead = _THINK_CLOSED
|
|
837
|
+
preview = " ".join(str(text or "").split()) or "reasoning summary"
|
|
838
|
+
preview_limit = max(12, int(width) - 2)
|
|
839
|
+
header = (
|
|
840
|
+
preview
|
|
841
|
+
if len(preview) <= preview_limit
|
|
842
|
+
else preview[: max(1, preview_limit - 1)].rstrip() + "…"
|
|
843
|
+
)
|
|
844
|
+
rows: list[list[tuple[str, str]]] = [[(rail, f"{lead} "), (body, header)]]
|
|
845
|
+
if not show_body:
|
|
846
|
+
return rows
|
|
847
|
+
inner = max(1, int(width) - 2) # room for the "│ " rail
|
|
848
|
+
for line in text.split("\n"):
|
|
849
|
+
for chunk in (textwrap.wrap(line, inner) if line else [""]) or [""]:
|
|
850
|
+
rows.append([(rail, f"{_THINK_RAIL} "), (body, chunk)])
|
|
851
|
+
return rows
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _activity_rows(
|
|
855
|
+
spinner: str,
|
|
856
|
+
label: str,
|
|
857
|
+
elapsed: int,
|
|
858
|
+
*,
|
|
859
|
+
elapsed_is_run_time: bool = False,
|
|
860
|
+
) -> list[list[tuple[str, str]]]:
|
|
861
|
+
"""The single live activity line shown under the content while a turn runs —
|
|
862
|
+
``⠋ thinking… Ns`` or ``⠋ Search Web… Ns``. Matches the live reasoning header
|
|
863
|
+
so the indicator and a streaming thought flow into each other seamlessly."""
|
|
864
|
+
rail = "class:tui.transcript.reasoningmark"
|
|
865
|
+
body = "class:tui.transcript.reasoning"
|
|
866
|
+
clock = (
|
|
867
|
+
f" \u00b7 run time {elapsed}s"
|
|
868
|
+
if elapsed and elapsed_is_run_time
|
|
869
|
+
else f" {elapsed}s"
|
|
870
|
+
if elapsed
|
|
871
|
+
else ""
|
|
872
|
+
)
|
|
873
|
+
return [[(rail, f"{spinner} "), (body, f"{label}{clock}")]]
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _sync_subagent_started_at(
|
|
877
|
+
started_at: dict[str, float],
|
|
878
|
+
names: tuple[str, ...],
|
|
879
|
+
*,
|
|
880
|
+
now: float,
|
|
881
|
+
) -> None:
|
|
882
|
+
active = {str(name) for name in names if str(name)}
|
|
883
|
+
for stale_name in set(started_at) - active:
|
|
884
|
+
started_at.pop(stale_name, None)
|
|
885
|
+
for name in active:
|
|
886
|
+
started_at.setdefault(name, float(now))
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _activity_elapsed_seconds(
|
|
890
|
+
*,
|
|
891
|
+
turn_started: float,
|
|
892
|
+
active_subagent: str,
|
|
893
|
+
subagent_started_at: dict[str, float],
|
|
894
|
+
now: float,
|
|
895
|
+
) -> int:
|
|
896
|
+
started = subagent_started_at.get(active_subagent) if active_subagent else None
|
|
897
|
+
if started is None:
|
|
898
|
+
started = turn_started
|
|
899
|
+
return max(0, int(float(now) - float(started))) if started else 0
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _plan_meta_rows(text: str, width: int, *, expanded: bool) -> list[list[tuple[str, str]]]:
|
|
903
|
+
"""Render captured Forge planner meta / plan-reconciliation notes as a
|
|
904
|
+
collapsible aside beneath the reply.
|
|
905
|
+
|
|
906
|
+
Collapsed (default): one ``▸`` summary line (an honest headline plus note /
|
|
907
|
+
task counts) so the assistant answer stays uncluttered. Expanded (Ctrl+O or
|
|
908
|
+
click): a ``▾`` header and the notes grouped per touched task — parsing,
|
|
909
|
+
grouping, and styling live in ``plan_meta`` (see that module's docstring).
|
|
910
|
+
Every row is ``<= width`` (the cursor-pin scroll math depends on it)."""
|
|
911
|
+
return render_plan_meta_rows(text, width, expanded=expanded)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _subagent_panel_container(panel_state: dict[str, Any]) -> ConditionalContainer:
|
|
915
|
+
"""Build the in-flow live-child pane around mutable ``panel_state``."""
|
|
916
|
+
|
|
917
|
+
def _fragments() -> FormattedText:
|
|
918
|
+
run_id = str(panel_state.get("selected_run_id") or "")
|
|
919
|
+
if not run_id:
|
|
920
|
+
return FormattedText([])
|
|
921
|
+
try:
|
|
922
|
+
cols = get_app().output.get_size().columns
|
|
923
|
+
except Exception:
|
|
924
|
+
cols = 80
|
|
925
|
+
width = max(12, int(cols) - 2)
|
|
926
|
+
position, total = _subagent_panel_view_position(panel_state, run_id)
|
|
927
|
+
statuses = panel_state.get("statuses", {})
|
|
928
|
+
entries = panel_state.get("entries", {})
|
|
929
|
+
status = statuses.get(run_id, {}) if isinstance(statuses, dict) else {}
|
|
930
|
+
buffered = entries.get(run_id, []) if isinstance(entries, dict) else []
|
|
931
|
+
rows = subagent_panel_rows(
|
|
932
|
+
status if isinstance(status, dict) else {},
|
|
933
|
+
buffered if isinstance(buffered, list) else [],
|
|
934
|
+
width=width,
|
|
935
|
+
height=_SUBAGENT_PANEL_CONTENT_HEIGHT,
|
|
936
|
+
position=position,
|
|
937
|
+
total=total,
|
|
938
|
+
)
|
|
939
|
+
fragments: list[tuple[str, str]] = []
|
|
940
|
+
for index, row in enumerate(rows):
|
|
941
|
+
fragments.extend(row)
|
|
942
|
+
if index < len(rows) - 1:
|
|
943
|
+
fragments.append(("", "\n"))
|
|
944
|
+
return FormattedText(fragments)
|
|
945
|
+
|
|
946
|
+
body = Window(
|
|
947
|
+
FormattedTextControl(_fragments, focusable=False),
|
|
948
|
+
wrap_lines=False,
|
|
949
|
+
style="class:tui.subpanel",
|
|
950
|
+
)
|
|
951
|
+
frame = Frame(
|
|
952
|
+
body,
|
|
953
|
+
title="Subagent live",
|
|
954
|
+
style="class:tui.subpanel",
|
|
955
|
+
height=D(min=3, preferred=_SUBAGENT_PANEL_HEIGHT, max=_SUBAGENT_PANEL_HEIGHT),
|
|
956
|
+
)
|
|
957
|
+
return ConditionalContainer(
|
|
958
|
+
frame,
|
|
959
|
+
filter=Condition(lambda: bool(panel_state.get("selected_run_id"))),
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _subagent_panel_view_position(
|
|
964
|
+
panel_state: dict[str, Any],
|
|
965
|
+
run_id: str,
|
|
966
|
+
) -> tuple[int, int]:
|
|
967
|
+
"""Return the selected child's current children-only view number."""
|
|
968
|
+
order = list(dict.fromkeys(str(value) for value in panel_state.get("run_order", []) if value))
|
|
969
|
+
try:
|
|
970
|
+
position = order.index(str(run_id)) + 1
|
|
971
|
+
except ValueError:
|
|
972
|
+
position = 1
|
|
973
|
+
return position, max(1, len(order))
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _cycle_subagent_panel(panel_state: dict[str, Any], delta: int) -> str:
|
|
977
|
+
"""Move through child runs in spawn order, wrapping within children."""
|
|
978
|
+
order = list(dict.fromkeys(str(value) for value in panel_state.get("run_order", []) if value))
|
|
979
|
+
panel_state["run_order"] = order
|
|
980
|
+
if not order:
|
|
981
|
+
panel_state["selected_run_id"] = ""
|
|
982
|
+
return ""
|
|
983
|
+
selected = str(panel_state.get("selected_run_id") or "")
|
|
984
|
+
try:
|
|
985
|
+
current = order.index(selected)
|
|
986
|
+
except ValueError:
|
|
987
|
+
current = -1 if int(delta) >= 0 else 0
|
|
988
|
+
target = (current + int(delta)) % len(order)
|
|
989
|
+
selected = order[target]
|
|
990
|
+
panel_state["selected_run_id"] = selected
|
|
991
|
+
panel_state["last_poll"] = None
|
|
992
|
+
return selected
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
_ACTIVE_SUBAGENT_STATES = frozenset({"spawned", "queued", "waiting", "running"})
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _subagents_picker_spec(
|
|
999
|
+
scheduler: Any,
|
|
1000
|
+
panel_state: dict[str, Any],
|
|
1001
|
+
) -> dict[str, Any] | None:
|
|
1002
|
+
"""Build the active-child picker and wire selection to the live panel."""
|
|
1003
|
+
status = scheduler.status()
|
|
1004
|
+
raw_children = status.get("children") if isinstance(status, dict) else None
|
|
1005
|
+
active_children = [
|
|
1006
|
+
dict(child)
|
|
1007
|
+
for child in (raw_children if isinstance(raw_children, list) else [])
|
|
1008
|
+
if isinstance(child, dict)
|
|
1009
|
+
and str(child.get("state") or "").strip().lower() in _ACTIVE_SUBAGENT_STATES
|
|
1010
|
+
and str(child.get("run_id") or "").strip()
|
|
1011
|
+
]
|
|
1012
|
+
if not active_children:
|
|
1013
|
+
return None
|
|
1014
|
+
|
|
1015
|
+
children_by_run_id = {str(child["run_id"]): child for child in active_children}
|
|
1016
|
+
selected_run_id = str(panel_state.get("selected_run_id") or "")
|
|
1017
|
+
rows: list[dict[str, Any]] = []
|
|
1018
|
+
for child in active_children:
|
|
1019
|
+
run_id = str(child["run_id"])
|
|
1020
|
+
state = str(child.get("state") or "unknown")
|
|
1021
|
+
activity = " ".join(str(child.get("activity") or "working").split())
|
|
1022
|
+
workspace = str(child.get("workspace_view") or "shared")
|
|
1023
|
+
steps = max(0, int(child.get("steps_completed") or 0))
|
|
1024
|
+
elapsed_ms = max(0, int(child.get("elapsed_ms") or 0))
|
|
1025
|
+
elapsed = f"{elapsed_ms // 1000}s"
|
|
1026
|
+
quiet = quiet_time_label(child)
|
|
1027
|
+
quiet_suffix = f" \u00b7 {quiet}" if quiet else ""
|
|
1028
|
+
rows.append(
|
|
1029
|
+
{
|
|
1030
|
+
"label": subagent_identity(
|
|
1031
|
+
child.get("subagent") or "subagent",
|
|
1032
|
+
child.get("label"),
|
|
1033
|
+
),
|
|
1034
|
+
"description": (
|
|
1035
|
+
f"{state} \u00b7 {activity} \u00b7 {workspace} \u00b7 "
|
|
1036
|
+
f"{steps} steps \u00b7 {elapsed}{quiet_suffix}"
|
|
1037
|
+
),
|
|
1038
|
+
"value": run_id,
|
|
1039
|
+
"current": run_id == selected_run_id,
|
|
1040
|
+
}
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
def _select(run_id: str) -> list[tuple[str, str]]:
|
|
1044
|
+
selected = children_by_run_id.get(str(run_id))
|
|
1045
|
+
if selected is None:
|
|
1046
|
+
return []
|
|
1047
|
+
selected_id = str(selected["run_id"])
|
|
1048
|
+
run_order = panel_state.setdefault("run_order", [])
|
|
1049
|
+
if selected_id not in run_order:
|
|
1050
|
+
run_order.append(selected_id)
|
|
1051
|
+
panel_state.setdefault("cursors", {}).setdefault(selected_id, 0)
|
|
1052
|
+
panel_state.setdefault("entries", {}).setdefault(selected_id, [])
|
|
1053
|
+
panel_state.setdefault("statuses", {})[selected_id] = dict(selected)
|
|
1054
|
+
panel_state.setdefault("lifecycles", {}).setdefault(
|
|
1055
|
+
selected_id,
|
|
1056
|
+
{
|
|
1057
|
+
"run_id": selected_id,
|
|
1058
|
+
"subagent": str(selected.get("subagent") or "subagent"),
|
|
1059
|
+
"state": str(selected.get("state") or ""),
|
|
1060
|
+
"collected": False,
|
|
1061
|
+
},
|
|
1062
|
+
)
|
|
1063
|
+
panel_state["selected_run_id"] = selected_id
|
|
1064
|
+
panel_state["last_poll"] = None
|
|
1065
|
+
return []
|
|
1066
|
+
|
|
1067
|
+
return {
|
|
1068
|
+
"title": "Active Subagents",
|
|
1069
|
+
"hint": "Enter open \u00b7 Esc close",
|
|
1070
|
+
"rows": rows,
|
|
1071
|
+
"on_select": _select,
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _evict_subagent_panel_runs(
|
|
1076
|
+
panel_state: dict[str, Any],
|
|
1077
|
+
*,
|
|
1078
|
+
departed_run_id: str,
|
|
1079
|
+
) -> list[dict[str, str]]:
|
|
1080
|
+
"""Drop only the terminal pane the operator has just navigated away from."""
|
|
1081
|
+
selected = str(panel_state.get("selected_run_id") or "")
|
|
1082
|
+
departed = str(departed_run_id or "").strip()
|
|
1083
|
+
if not departed or departed == selected:
|
|
1084
|
+
return []
|
|
1085
|
+
lifecycles = panel_state.get("lifecycles", {})
|
|
1086
|
+
statuses = panel_state.get("statuses", {})
|
|
1087
|
+
if departed not in panel_state.get("run_order", []):
|
|
1088
|
+
return []
|
|
1089
|
+
lifecycle = lifecycles.get(departed, {}) if isinstance(lifecycles, dict) else {}
|
|
1090
|
+
if str(lifecycle.get("state") or "") not in {"joined", "cancelled"}:
|
|
1091
|
+
return []
|
|
1092
|
+
status = statuses.get(departed, {}) if isinstance(statuses, dict) else {}
|
|
1093
|
+
name = str(status.get("subagent") or lifecycle.get("subagent") or "subagent")
|
|
1094
|
+
outcome = str(lifecycle.get("outcome") or "").strip().lower()
|
|
1095
|
+
panel_state["run_order"].remove(departed)
|
|
1096
|
+
for key in ("cursors", "entries", "statuses", "lifecycles", "poll_failures"):
|
|
1097
|
+
mapping = panel_state.get(key)
|
|
1098
|
+
if isinstance(mapping, dict):
|
|
1099
|
+
mapping.pop(departed, None)
|
|
1100
|
+
return [{"run_id": departed, "subagent": name, "outcome": outcome}]
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _poll_selected_subagent(
|
|
1104
|
+
panel_state: dict[str, Any],
|
|
1105
|
+
scheduler: Any,
|
|
1106
|
+
*,
|
|
1107
|
+
now: float | None = None,
|
|
1108
|
+
on_failure: Callable[[str, str], None] | None = None,
|
|
1109
|
+
) -> bool:
|
|
1110
|
+
"""Incrementally refresh only the selected child, at most every 0.4s."""
|
|
1111
|
+
run_id = str(panel_state.get("selected_run_id") or "")
|
|
1112
|
+
if not run_id:
|
|
1113
|
+
return False
|
|
1114
|
+
|
|
1115
|
+
def _record_failure(condition: str) -> bool:
|
|
1116
|
+
failures = panel_state.setdefault("poll_failures", {})
|
|
1117
|
+
if not isinstance(failures, dict) or run_id in failures:
|
|
1118
|
+
return False
|
|
1119
|
+
failures[run_id] = condition
|
|
1120
|
+
if on_failure is not None:
|
|
1121
|
+
try:
|
|
1122
|
+
on_failure(run_id, condition)
|
|
1123
|
+
except Exception:
|
|
1124
|
+
pass
|
|
1125
|
+
return True
|
|
1126
|
+
|
|
1127
|
+
view_since = getattr(scheduler, "view_since", None)
|
|
1128
|
+
if scheduler is None or not callable(view_since):
|
|
1129
|
+
return _record_failure("view_since_unavailable")
|
|
1130
|
+
stamp = time.monotonic() if now is None else float(now)
|
|
1131
|
+
last_poll = panel_state.get("last_poll")
|
|
1132
|
+
if isinstance(last_poll, (int, float)) and stamp - float(last_poll) < 0.4:
|
|
1133
|
+
return False
|
|
1134
|
+
panel_state["last_poll"] = stamp
|
|
1135
|
+
cursors = panel_state.setdefault("cursors", {})
|
|
1136
|
+
cursor = max(0, int(cursors.get(run_id, 0)))
|
|
1137
|
+
try:
|
|
1138
|
+
payload = view_since(run_id=run_id, cursor=cursor)
|
|
1139
|
+
except Exception:
|
|
1140
|
+
return _record_failure("view_since_exception")
|
|
1141
|
+
if not isinstance(payload, dict) or payload.get("error"):
|
|
1142
|
+
return _record_failure("view_since_error_payload")
|
|
1143
|
+
try:
|
|
1144
|
+
cursors[run_id] = max(cursor, int(payload.get("next_cursor", cursor)))
|
|
1145
|
+
except (TypeError, ValueError):
|
|
1146
|
+
cursors[run_id] = cursor
|
|
1147
|
+
raw_entries = payload.get("transcript_tail")
|
|
1148
|
+
incoming = [
|
|
1149
|
+
{
|
|
1150
|
+
"kind": str(entry.get("kind") or ""),
|
|
1151
|
+
"summary": str(entry.get("summary") or ""),
|
|
1152
|
+
}
|
|
1153
|
+
for entry in (raw_entries if isinstance(raw_entries, list) else [])
|
|
1154
|
+
if isinstance(entry, dict)
|
|
1155
|
+
]
|
|
1156
|
+
entries = panel_state.setdefault("entries", {})
|
|
1157
|
+
entries[run_id] = append_bounded_entries(entries.get(run_id, []), incoming)
|
|
1158
|
+
panel_state.setdefault("statuses", {})[run_id] = {
|
|
1159
|
+
key: value
|
|
1160
|
+
for key, value in payload.items()
|
|
1161
|
+
if key not in {"transcript_tail", "next_cursor"}
|
|
1162
|
+
}
|
|
1163
|
+
return True
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
# --------------------------------------------------------- live Forge view
|
|
1167
|
+
# The status→bucket/glyph authority lives in ``forge_status`` (imported above), so
|
|
1168
|
+
# a row's glyph can never contradict the header's "N done · N failed" count.
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _forge_task_visual(status: str, active: bool, spinner: str) -> tuple[str, str]:
|
|
1172
|
+
"""Return ``(glyph, style_class)`` for one forge task row given its status and
|
|
1173
|
+
whether the swarm is currently working it. Done=✓ green, failed=✗ red, the
|
|
1174
|
+
in-flight task spins (violet), everything else is a dim ``○``."""
|
|
1175
|
+
glyph = forge_status_glyph(status, active=active, spinner=spinner)
|
|
1176
|
+
bucket = forge_status_bucket(status)
|
|
1177
|
+
if bucket == "done":
|
|
1178
|
+
return glyph, "class:tui.forge.done"
|
|
1179
|
+
if bucket == "failed":
|
|
1180
|
+
return glyph, "class:tui.forge.fail"
|
|
1181
|
+
if bucket == "running" or active:
|
|
1182
|
+
# A running task — or one the swarm just picked up (event arrived before
|
|
1183
|
+
# its on-disk status flipped) — spins in violet.
|
|
1184
|
+
return glyph, "class:tui.forge.run"
|
|
1185
|
+
return glyph, "class:tui.forge.idle"
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _forge_fmt_elapsed(seconds: int) -> str:
|
|
1189
|
+
"""Compact elapsed like ``12s`` / ``1m04s`` — bounded to AT MOST 6 chars so it
|
|
1190
|
+
always fits the reserved timer column (the row width invariant depends on it)."""
|
|
1191
|
+
seconds = max(0, int(seconds))
|
|
1192
|
+
if seconds < 60:
|
|
1193
|
+
return f"{seconds}s"
|
|
1194
|
+
minutes, secs = divmod(seconds, 60)
|
|
1195
|
+
if minutes < 100:
|
|
1196
|
+
return f"{minutes}m{secs:02d}s"
|
|
1197
|
+
if minutes < 100000:
|
|
1198
|
+
return f"{minutes}m" # "100m".."99999m" — still <= 6 chars
|
|
1199
|
+
return "99999m" # clamp an absurd/stuck elapsed so the 6-char contract holds
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _forge_header_fragments(
|
|
1203
|
+
width: int, run_id: str, done_n: int, failed_n: int, total: int
|
|
1204
|
+
) -> list[tuple[str, str]]:
|
|
1205
|
+
"""Build the live header — ``FORGE · run-xxx ███░░ 5/9`` — as styled
|
|
1206
|
+
fragments that sum to EXACTLY ``width`` columns (falling back to a dim rule
|
|
1207
|
+
when the panel is too narrow for a bar). Only narrow, single-column glyphs
|
|
1208
|
+
are used (block/▓/░/─) so the char count matches the display width and the
|
|
1209
|
+
cursor-pin scroll math stays exact."""
|
|
1210
|
+
width = max(1, int(width))
|
|
1211
|
+
run_label = f"FORGE · {run_id}" if run_id else "FORGE"
|
|
1212
|
+
count = f"{done_n}/{total}" if total else ""
|
|
1213
|
+
# Right block is " <count>" (a leading space), clipped so it can never exceed
|
|
1214
|
+
# the row on an ultra-narrow panel.
|
|
1215
|
+
right = (" " + count) if count else ""
|
|
1216
|
+
right = right[:width]
|
|
1217
|
+
left_avail = width - len(right)
|
|
1218
|
+
label = run_label[: max(0, left_avail)]
|
|
1219
|
+
mid_w = left_avail - len(label) # columns between the label and the count
|
|
1220
|
+
|
|
1221
|
+
frags: list[tuple[str, str]] = [("class:tui.forge.head", label)]
|
|
1222
|
+
if total > 0 and mid_w >= 6:
|
|
1223
|
+
# " " + bar(mid_w-2) + " "
|
|
1224
|
+
bar_w = mid_w - 2
|
|
1225
|
+
done_seg = min(bar_w, round(bar_w * done_n / total))
|
|
1226
|
+
fail_seg = min(bar_w - done_seg, round(bar_w * failed_n / total))
|
|
1227
|
+
empty_seg = bar_w - done_seg - fail_seg
|
|
1228
|
+
frags.append(("class:tui.forge.rule", " "))
|
|
1229
|
+
if done_seg:
|
|
1230
|
+
frags.append(("class:tui.forge.meter", "█" * done_seg))
|
|
1231
|
+
if fail_seg:
|
|
1232
|
+
frags.append(("class:tui.forge.meterfail", "█" * fail_seg))
|
|
1233
|
+
if empty_seg:
|
|
1234
|
+
frags.append(("class:tui.forge.meterempty", "░" * empty_seg))
|
|
1235
|
+
frags.append(("class:tui.forge.rule", " "))
|
|
1236
|
+
elif mid_w >= 2:
|
|
1237
|
+
frags.append(("class:tui.forge.rule", " " + "─" * (mid_w - 1)))
|
|
1238
|
+
elif mid_w == 1:
|
|
1239
|
+
frags.append(("class:tui.forge.rule", " "))
|
|
1240
|
+
if right:
|
|
1241
|
+
frags.append(("class:tui.forge.count", right))
|
|
1242
|
+
return frags
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _forge_view_rows(
|
|
1246
|
+
view: dict[str, Any],
|
|
1247
|
+
width: int,
|
|
1248
|
+
spinner: str,
|
|
1249
|
+
elapsed: int,
|
|
1250
|
+
task_elapsed: dict[str, int] | None = None,
|
|
1251
|
+
) -> list[list[tuple[str, str]]]:
|
|
1252
|
+
"""Render the live Forge execution block: a violet header with a progress
|
|
1253
|
+
gauge, a per-task status table (glyph · id · status · title · timer) and a
|
|
1254
|
+
phase/spinner line. ``task_elapsed`` maps a running task id to its elapsed
|
|
1255
|
+
seconds (from the transcript's per-task ``active_map``); when omitted, no
|
|
1256
|
+
per-row timers are drawn. Every row is ``<= width`` so the transcript's
|
|
1257
|
+
cursor-pin scroll math stays exact."""
|
|
1258
|
+
width = max(20, int(width))
|
|
1259
|
+
rows: list[list[tuple[str, str]]] = []
|
|
1260
|
+
run_id = str(view.get("run_id") or "")
|
|
1261
|
+
done = bool(view.get("done"))
|
|
1262
|
+
tasks = list(view.get("tasks") or [])
|
|
1263
|
+
active = view.get("active")
|
|
1264
|
+
active_map = view.get("active_map") or {}
|
|
1265
|
+
timers = dict(task_elapsed or {})
|
|
1266
|
+
|
|
1267
|
+
# Header: "FORGE · run-xxx ███░░ 5/9" — a live progress gauge (done in
|
|
1268
|
+
# violet, failed in red, the rest a dim track) so run progress is glanceable.
|
|
1269
|
+
done_n, failed_n, _remaining_n = forge_status_counts(
|
|
1270
|
+
str(t.get("status", "") or "planned") for t in tasks
|
|
1271
|
+
)
|
|
1272
|
+
rows.append(_forge_header_fragments(width, run_id, done_n, failed_n, len(tasks)))
|
|
1273
|
+
|
|
1274
|
+
# Task table — aligned id + status columns, title fills the rest, and a
|
|
1275
|
+
# right-aligned elapsed timer on each running row. Column widths are capped
|
|
1276
|
+
# RELATIVE to the width so the fixed prefix can never exceed the panel (which
|
|
1277
|
+
# would silently wrap the row and break the scroll math).
|
|
1278
|
+
if tasks:
|
|
1279
|
+
id_w = min(
|
|
1280
|
+
max((len(str(t.get("id", ""))) for t in tasks), default=2), 8, max(2, width // 6)
|
|
1281
|
+
)
|
|
1282
|
+
status_w = min(
|
|
1283
|
+
max((len(str(t.get("status", ""))) for t in tasks), default=7), 16, max(4, width // 4)
|
|
1284
|
+
)
|
|
1285
|
+
prefix = 2 + 2 + id_w + 2 + status_w + 2 # indent+glyph+id+gap+status+gap
|
|
1286
|
+
# Reserve a right-hand timer column only when a running task actually has
|
|
1287
|
+
# a timer AND the panel is wide enough to spare it.
|
|
1288
|
+
timer_strs = {tid: _forge_fmt_elapsed(sec) for tid, sec in timers.items()}
|
|
1289
|
+
timer_w = min(6, max((len(s) for s in timer_strs.values()), default=0))
|
|
1290
|
+
reserve = (timer_w + 1) if (timer_w and width - prefix > timer_w + 4) else 0
|
|
1291
|
+
title_w = max(0, width - prefix - reserve)
|
|
1292
|
+
for task in tasks:
|
|
1293
|
+
tid = str(task.get("id", ""))
|
|
1294
|
+
status = str(task.get("status", "") or "planned")
|
|
1295
|
+
title = str(task.get("title", "") or "")
|
|
1296
|
+
is_active = tid in active_map or (active is not None and tid == active)
|
|
1297
|
+
glyph, gstyle = _forge_task_visual(status, is_active, spinner)
|
|
1298
|
+
id_cell = tid[:id_w].ljust(id_w)
|
|
1299
|
+
status_cell = status[:status_w].ljust(status_w)
|
|
1300
|
+
row = [
|
|
1301
|
+
("class:tui.forge.idle", " "),
|
|
1302
|
+
(gstyle, f"{glyph} "),
|
|
1303
|
+
("class:tui.forge.id", f"{id_cell} "),
|
|
1304
|
+
(gstyle, f"{status_cell} "),
|
|
1305
|
+
(
|
|
1306
|
+
"class:tui.forge.title",
|
|
1307
|
+
title[:title_w].ljust(title_w) if reserve else title[:title_w],
|
|
1308
|
+
),
|
|
1309
|
+
]
|
|
1310
|
+
if reserve:
|
|
1311
|
+
ts = timer_strs.get(tid, "")
|
|
1312
|
+
# Blank (spaces) for a non-running row keeps the row within width
|
|
1313
|
+
# without misaligning the running rows' timers. The timer is clipped
|
|
1314
|
+
# to the reserved column so an over-long value can never overflow.
|
|
1315
|
+
row.append(
|
|
1316
|
+
(
|
|
1317
|
+
"class:tui.forge.timer",
|
|
1318
|
+
" " + ts[:timer_w].rjust(timer_w) if ts else " " * (timer_w + 1),
|
|
1319
|
+
)
|
|
1320
|
+
)
|
|
1321
|
+
rows.append(row)
|
|
1322
|
+
else:
|
|
1323
|
+
rows.append([("class:tui.forge.idle", " (no execution-ready tasks)"[: max(0, width)])])
|
|
1324
|
+
|
|
1325
|
+
# Phase / spinner status line.
|
|
1326
|
+
message = str(view.get("message") or "")
|
|
1327
|
+
if done:
|
|
1328
|
+
# Colour the summary by the run outcome so a green ✓ never sits next to a
|
|
1329
|
+
# failed task: green ✓ on a clean run, red ✗ when anything failed/remains.
|
|
1330
|
+
ok = bool(view.get("ok", True))
|
|
1331
|
+
glyph = "✓" if ok else "✗"
|
|
1332
|
+
gstyle = "class:tui.forge.done" if ok else "class:tui.forge.fail"
|
|
1333
|
+
line = (message or ("Done." if ok else "Finished with issues."))[: max(0, width - 2)]
|
|
1334
|
+
rows.append([(gstyle, f"{glyph} "), ("class:tui.transcript.system", line)])
|
|
1335
|
+
else:
|
|
1336
|
+
phase = str(view.get("phase") or "execute")
|
|
1337
|
+
running = len(active_map)
|
|
1338
|
+
lead = f"{running} running · " if running > 1 else ""
|
|
1339
|
+
text = f"{lead}{phase}" if not message else f"{lead}{phase} · {message}"
|
|
1340
|
+
if elapsed:
|
|
1341
|
+
text = f"{text} · {elapsed}s"
|
|
1342
|
+
text = text[: max(0, width - 2)]
|
|
1343
|
+
rows.append(
|
|
1344
|
+
[("class:tui.forge.run", f"{spinner} "), ("class:tui.transcript.reasoning", text)]
|
|
1345
|
+
)
|
|
1346
|
+
return rows
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
# ------------------------------------------------------------- float geometry
|
|
1350
|
+
# Every popup renders pre-wrapped, pre-padded rows into a Window(wrap_lines=True).
|
|
1351
|
+
# That only holds together while the width a row builder used equals the width
|
|
1352
|
+
# prompt_toolkit actually paints into: a row even ONE column too wide is
|
|
1353
|
+
# hard-character-wrapped to column 0 — mid-word, straight through the aligned
|
|
1354
|
+
# label/description columns — and the doubled screen rows overflow the float's
|
|
1355
|
+
# height, clipping the bottom (hint lines, key legends) off the box.
|
|
1356
|
+
#
|
|
1357
|
+
# So each float's content width is derived HERE from that float's OWN outer width
|
|
1358
|
+
# minus its OWN chrome. Never borrow a sibling's formula: the popups are
|
|
1359
|
+
# deliberately different widths, and a borrowed one is wrong at every size.
|
|
1360
|
+
_FRAME_COLS = 2 # Frame draws one border column on each side
|
|
1361
|
+
_SCROLLBAR_COLS = 1 # ScrollbarMargin takes one column away from the content
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _picker_width_for(cols: int) -> int:
|
|
1365
|
+
"""Outer width of the picker float on a ``cols``-wide terminal."""
|
|
1366
|
+
return max(40, min(int(cols) - 8, 76))
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _picker_content_width_for(cols: int) -> int:
|
|
1370
|
+
"""Width the picker's rows must render to (framed; no margins)."""
|
|
1371
|
+
return max(20, _picker_width_for(cols) - _FRAME_COLS)
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _help_panel_width_for(cols: int) -> int:
|
|
1375
|
+
"""Outer width of the help/panel float.
|
|
1376
|
+
|
|
1377
|
+
Wide side margins so the panel clearly floats over the app rather than
|
|
1378
|
+
filling the screen, and a cap so it is never absurdly wide.
|
|
1379
|
+
"""
|
|
1380
|
+
return max(38, min(int(cols) - 8, 86))
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _help_content_width_for(cols: int) -> int:
|
|
1384
|
+
"""Width the help/panel rows must render to (framed AND scrollable)."""
|
|
1385
|
+
return max(20, _help_panel_width_for(cols) - _FRAME_COLS - _SCROLLBAR_COLS)
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _approval_width_for(cols: int) -> int:
|
|
1389
|
+
"""Outer width of the approval modal."""
|
|
1390
|
+
return max(40, min(int(cols) - 8, 72))
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _approval_content_width_for(cols: int) -> int:
|
|
1394
|
+
"""Width the approval modal's rows must render to (framed; no margins)."""
|
|
1395
|
+
return max(20, _approval_width_for(cols) - _FRAME_COLS)
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _transcript_content_width_for(cols: int) -> int:
|
|
1399
|
+
"""Width the transcript's rows must render to.
|
|
1400
|
+
|
|
1401
|
+
The transcript is the full-width pane (no frame) but carries a scrollbar
|
|
1402
|
+
margin, so it is one column narrower than the terminal.
|
|
1403
|
+
"""
|
|
1404
|
+
return max(1, int(cols) - _SCROLLBAR_COLS)
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
# ----------------------------------------------------------------- /help popup
|
|
1408
|
+
HelpSection = tuple[str, list[tuple[str, str]]]
|
|
1409
|
+
|
|
1410
|
+
_HELP_HINT = "↑↓ / wheel scroll · End bottom · Esc close"
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def _clip_cell(text: str, limit: int) -> str:
|
|
1414
|
+
"""Hard-clip ``text`` to ``limit`` columns with an ellipsis.
|
|
1415
|
+
|
|
1416
|
+
Guarantees the layout contract every panel builder shares: an emitted row
|
|
1417
|
+
must be exactly the panel width (the cursor-pin scroll math relies on it, and
|
|
1418
|
+
an over-wide row re-wraps to column 0), so a label longer than its column is
|
|
1419
|
+
clipped rather than allowed to push the row out.
|
|
1420
|
+
"""
|
|
1421
|
+
limit = max(0, limit)
|
|
1422
|
+
if len(text) <= limit:
|
|
1423
|
+
return text
|
|
1424
|
+
if limit <= 1:
|
|
1425
|
+
return text[:limit]
|
|
1426
|
+
return text[: limit - 1] + "…"
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _fit_label_column(label_w: int, width: int, reserved: int) -> int:
|
|
1430
|
+
"""Shrink a left label column so the row still fits ``width``.
|
|
1431
|
+
|
|
1432
|
+
Flooring the *right* column instead (``max(10, width - label_w - gap)``) is
|
|
1433
|
+
the trap: on a narrow panel it silently emits rows wider than the window,
|
|
1434
|
+
which then hard-wrap. The label column is the one that must give way — it is
|
|
1435
|
+
clipped, whereas the right column wraps.
|
|
1436
|
+
|
|
1437
|
+
Capped at half the remaining room so one long command/key can never squeeze
|
|
1438
|
+
the descriptions down to a few columns and shred them one letter per line.
|
|
1439
|
+
At normal widths the label cap (30 / 22) binds first, so this changes nothing.
|
|
1440
|
+
"""
|
|
1441
|
+
room = max(0, int(width) - int(reserved))
|
|
1442
|
+
return max(0, min(int(label_w), room // 2))
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def _fallback_help_sections() -> list[HelpSection]:
|
|
1446
|
+
"""Static command list used if the canonical grouped help can't be imported."""
|
|
1447
|
+
try:
|
|
1448
|
+
from ..chat_slash_completer import get_chat_specs
|
|
1449
|
+
|
|
1450
|
+
return [("Commands", [(spec.usage, spec.description) for spec in get_chat_specs()])]
|
|
1451
|
+
except Exception:
|
|
1452
|
+
return [
|
|
1453
|
+
("Commands", [("/help", "Show available commands"), ("/exit", "Exit chat")]),
|
|
1454
|
+
]
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def _help_rows_for_sections(sections: list[HelpSection], width: int) -> list[list[tuple[str, str]]]:
|
|
1458
|
+
"""Render help ``sections`` into padded fragment rows for the popup.
|
|
1459
|
+
|
|
1460
|
+
Each command-row is ``<green command> <dim description>`` with the commands
|
|
1461
|
+
left-aligned in a shared column so they line up and read easily; long
|
|
1462
|
+
descriptions wrap with a hanging indent under the description column. Every
|
|
1463
|
+
row is padded to ``width`` so the panel background fills as a solid block.
|
|
1464
|
+
"""
|
|
1465
|
+
width = max(20, int(width))
|
|
1466
|
+
pad = "class:tui.help"
|
|
1467
|
+
cmd_style = "class:tui.help.cmd"
|
|
1468
|
+
desc_style = "class:tui.help.desc"
|
|
1469
|
+
sec_style = "class:tui.help.section"
|
|
1470
|
+
hint_style = "class:tui.help.hint"
|
|
1471
|
+
|
|
1472
|
+
all_cmds = [cmd for _name, rows in sections for cmd, _desc in rows]
|
|
1473
|
+
gap = 2
|
|
1474
|
+
cmd_w = _fit_label_column(min(max((len(c) for c in all_cmds), default=0), 30), width, gap)
|
|
1475
|
+
desc_w = max(1, width - cmd_w - gap)
|
|
1476
|
+
|
|
1477
|
+
def _pad(fragments: list[tuple[str, str]], used: int) -> list[tuple[str, str]]:
|
|
1478
|
+
return [*fragments, (pad, " " * max(0, width - used))]
|
|
1479
|
+
|
|
1480
|
+
out: list[list[tuple[str, str]]] = []
|
|
1481
|
+
for index, (name, rows) in enumerate(sections):
|
|
1482
|
+
if index > 0:
|
|
1483
|
+
out.append([(pad, " " * width)]) # blank row between sections
|
|
1484
|
+
header = _clip_cell(name, width)
|
|
1485
|
+
out.append(_pad([(sec_style, header)], len(header)))
|
|
1486
|
+
for cmd, desc in rows:
|
|
1487
|
+
chunks = _wrap_line(desc, desc_w)
|
|
1488
|
+
for i, chunk in enumerate(chunks):
|
|
1489
|
+
if i == 0:
|
|
1490
|
+
out.append(
|
|
1491
|
+
_pad(
|
|
1492
|
+
[
|
|
1493
|
+
(cmd_style, _clip_cell(cmd, cmd_w).ljust(cmd_w)),
|
|
1494
|
+
(pad, " " * gap),
|
|
1495
|
+
(desc_style, chunk),
|
|
1496
|
+
],
|
|
1497
|
+
cmd_w + gap + len(chunk),
|
|
1498
|
+
)
|
|
1499
|
+
)
|
|
1500
|
+
else:
|
|
1501
|
+
out.append(
|
|
1502
|
+
_pad(
|
|
1503
|
+
[(pad, " " * (cmd_w + gap)), (desc_style, chunk)],
|
|
1504
|
+
cmd_w + gap + len(chunk),
|
|
1505
|
+
)
|
|
1506
|
+
)
|
|
1507
|
+
out.append([(pad, " " * width)])
|
|
1508
|
+
# Wrap (never emit raw): the hint is 42 columns and would overflow — and thus
|
|
1509
|
+
# re-wrap to column 0 — on any narrower panel. Both sibling builders wrap theirs.
|
|
1510
|
+
for segment in str(_HELP_HINT).split("\n"):
|
|
1511
|
+
for hline in _wrap_line(segment, width):
|
|
1512
|
+
out.append(_pad([(hint_style, hline)], len(hline)))
|
|
1513
|
+
return out
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
# A key/value panel section: a header plus ``(key, value, tone)`` rows. ``tone``
|
|
1517
|
+
# selects the value colour: "accent" (on/healthy, green), "plain" (neutral),
|
|
1518
|
+
# "warn" (amber), "err" (red), "dim" (muted). Anything else falls back to plain.
|
|
1519
|
+
PanelSection = tuple[str, list[tuple[str, str, str]]]
|
|
1520
|
+
|
|
1521
|
+
_PANEL_TONE_STYLE = {
|
|
1522
|
+
"accent": "class:tui.help.accent",
|
|
1523
|
+
"plain": "class:tui.help.desc",
|
|
1524
|
+
"warn": "class:tui.help.warn",
|
|
1525
|
+
"err": "class:tui.help.err",
|
|
1526
|
+
"dim": "class:tui.help.key",
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _render_kv_panel_rows(
|
|
1531
|
+
sections: list[PanelSection], width: int, hint: str = _HELP_HINT
|
|
1532
|
+
) -> list[list[tuple[str, str]]]:
|
|
1533
|
+
"""Render key/value ``sections`` into padded fragment rows for a popup panel.
|
|
1534
|
+
|
|
1535
|
+
Each row is ``<dim key> <toned value>`` with the keys left-aligned in a shared
|
|
1536
|
+
column so they line up; long values wrap with a hanging indent under the value
|
|
1537
|
+
column. Section headers use the same bright style as /help, a blank row sits
|
|
1538
|
+
between sections, and a dim hint closes the panel. Every row is padded to
|
|
1539
|
+
``width`` so the panel background fills as a solid block — exactly like
|
|
1540
|
+
:func:`_help_rows_for_sections`, so both feed the one reusable panel overlay.
|
|
1541
|
+
"""
|
|
1542
|
+
width = max(20, int(width))
|
|
1543
|
+
pad = "class:tui.help"
|
|
1544
|
+
key_style = "class:tui.help.key"
|
|
1545
|
+
sec_style = "class:tui.help.section"
|
|
1546
|
+
hint_style = "class:tui.help.hint"
|
|
1547
|
+
indent = 2
|
|
1548
|
+
|
|
1549
|
+
all_keys = [key for _name, rows in sections for key, _val, _tone in rows]
|
|
1550
|
+
gap = 2
|
|
1551
|
+
key_w = _fit_label_column(
|
|
1552
|
+
min(max((len(key) for key in all_keys), default=0), 22), width, indent + gap
|
|
1553
|
+
)
|
|
1554
|
+
val_w = max(1, width - indent - key_w - gap)
|
|
1555
|
+
|
|
1556
|
+
def _pad(fragments: list[tuple[str, str]], used: int) -> list[tuple[str, str]]:
|
|
1557
|
+
return [*fragments, (pad, " " * max(0, width - used))]
|
|
1558
|
+
|
|
1559
|
+
_clip = _clip_cell
|
|
1560
|
+
header_w = max(0, width - indent)
|
|
1561
|
+
out: list[list[tuple[str, str]]] = []
|
|
1562
|
+
for index, (name, rows) in enumerate(sections):
|
|
1563
|
+
if index > 0:
|
|
1564
|
+
out.append([(pad, " " * width)]) # blank row between sections
|
|
1565
|
+
header = _clip(name, header_w)
|
|
1566
|
+
out.append(_pad([(pad, " " * indent), (sec_style, header)], indent + len(header)))
|
|
1567
|
+
for key, value, tone in rows:
|
|
1568
|
+
val_style = _PANEL_TONE_STYLE.get(tone, "class:tui.help.desc")
|
|
1569
|
+
key_cell = _clip(key, key_w).ljust(key_w)
|
|
1570
|
+
chunks = _wrap_line(str(value), val_w)
|
|
1571
|
+
for i, chunk in enumerate(chunks):
|
|
1572
|
+
if i == 0:
|
|
1573
|
+
out.append(
|
|
1574
|
+
_pad(
|
|
1575
|
+
[
|
|
1576
|
+
(pad, " " * indent),
|
|
1577
|
+
(key_style, key_cell),
|
|
1578
|
+
(pad, " " * gap),
|
|
1579
|
+
(val_style, chunk),
|
|
1580
|
+
],
|
|
1581
|
+
indent + key_w + gap + len(chunk),
|
|
1582
|
+
)
|
|
1583
|
+
)
|
|
1584
|
+
else:
|
|
1585
|
+
out.append(
|
|
1586
|
+
_pad(
|
|
1587
|
+
[(pad, " " * (indent + key_w + gap)), (val_style, chunk)],
|
|
1588
|
+
indent + key_w + gap + len(chunk),
|
|
1589
|
+
)
|
|
1590
|
+
)
|
|
1591
|
+
out.append([(pad, " " * width)])
|
|
1592
|
+
if hint:
|
|
1593
|
+
# Honour explicit line breaks, then wrap (never clip) so a long hint is
|
|
1594
|
+
# never cut off at the right edge.
|
|
1595
|
+
for segment in str(hint).split("\n"):
|
|
1596
|
+
for hline in _wrap_line(segment, width):
|
|
1597
|
+
out.append(_pad([(hint_style, hline)], len(hline)))
|
|
1598
|
+
return out
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _render_doc_panel_rows(
|
|
1602
|
+
text: str,
|
|
1603
|
+
width: int,
|
|
1604
|
+
hint: str = _HELP_HINT,
|
|
1605
|
+
*,
|
|
1606
|
+
theme: TerminalTheme = "neutral",
|
|
1607
|
+
) -> list[list[tuple[str, str]]]:
|
|
1608
|
+
"""Render freeform document text into rows for the centered popup panel.
|
|
1609
|
+
|
|
1610
|
+
Markdown text (PLAN.md, a README preview, …) is rendered through the same Rich
|
|
1611
|
+
pipeline as assistant replies — headings, lists, tables, syntax-highlighted
|
|
1612
|
+
code — so ``/plan markdown`` reads like a real document instead of a pager
|
|
1613
|
+
dump. Non-markdown text falls back to plain wrapped lines. Every row is
|
|
1614
|
+
``<= width`` so the cursor-pin scroll math (which counts logical rows) stays
|
|
1615
|
+
exact; the popup window's dark background fills the rest of each row.
|
|
1616
|
+
"""
|
|
1617
|
+
width = max(20, int(width))
|
|
1618
|
+
pad = "class:tui.help"
|
|
1619
|
+
body_style = "class:tui.help.desc"
|
|
1620
|
+
hint_style = "class:tui.help.hint"
|
|
1621
|
+
out: list[list[tuple[str, str]]] = []
|
|
1622
|
+
md_rows = render_markdown_rows(text, width, theme)
|
|
1623
|
+
if md_rows is not None:
|
|
1624
|
+
# render_markdown_rows is memoized and returns cache-owned lists; copy each
|
|
1625
|
+
# inner row so this panel can never mutate a shared cache entry in place.
|
|
1626
|
+
out.extend([list(row) for row in md_rows])
|
|
1627
|
+
else:
|
|
1628
|
+
for line in text.split("\n") or [""]:
|
|
1629
|
+
for chunk in _wrap_line(line, width):
|
|
1630
|
+
out.append([(body_style, chunk)])
|
|
1631
|
+
if not out:
|
|
1632
|
+
out.append([(body_style, "(empty)")])
|
|
1633
|
+
out.append([(pad, " " * width)])
|
|
1634
|
+
if hint:
|
|
1635
|
+
for segment in str(hint).split("\n"):
|
|
1636
|
+
for hline in _wrap_line(segment, width):
|
|
1637
|
+
out.append([(hint_style, hline)])
|
|
1638
|
+
return out
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
# A picker row: a selectable option with a label, a one-line description, the
|
|
1642
|
+
# value passed to the select callback, whether it is the current setting, and an
|
|
1643
|
+
# optional "tag" override (e.g. "(default)"; empty string suppresses the tag).
|
|
1644
|
+
PickerRow = dict[str, Any]
|
|
1645
|
+
|
|
1646
|
+
_PICKER_HINT = "↑↓ move · 1-9 pick · Enter select · Esc cancel"
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
# Most lines a single picker option's description may wrap to before it is capped
|
|
1650
|
+
# with an ellipsis (so a long blurb takes a few lines, never ten).
|
|
1651
|
+
_PICKER_MAX_DESC_LINES = 3
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def _render_picker_rows(
|
|
1655
|
+
rows: list[PickerRow], index: int, width: int, hint: str = _PICKER_HINT, numbered: bool = True
|
|
1656
|
+
) -> list[list[tuple[str, str]]]:
|
|
1657
|
+
"""Render a numbered, selectable option list for the picker popup.
|
|
1658
|
+
|
|
1659
|
+
Each option is ``› N. label description`` with the descriptions aligned in a
|
|
1660
|
+
shared column; a description that does not fit on one line **wraps** onto
|
|
1661
|
+
continuation lines (indented under the column), capped at
|
|
1662
|
+
:data:`_PICKER_MAX_DESC_LINES` so it never grows unbounded. The focused row
|
|
1663
|
+
(``index``) is painted with the band background and an accent caret/label
|
|
1664
|
+
across all its lines, and the active setting is tagged "(current)" — unless
|
|
1665
|
+
the row carries a ``tag`` key, which overrides the tag text (an empty string
|
|
1666
|
+
suppresses it entirely). Every row is padded to ``width`` so the highlight
|
|
1667
|
+
band stays aligned.
|
|
1668
|
+
|
|
1669
|
+
Rows may carry a ``kind``: "header" paints a dim upper-case section caption
|
|
1670
|
+
and "spacer" a blank band line — both flush-left, unnumbered, unselectable.
|
|
1671
|
+
With ``numbered=False`` the ``N.`` prefixes are dropped (grouped menus
|
|
1672
|
+
navigate by arrows, not digits). A row ``tone`` of "warn" tints the
|
|
1673
|
+
description so problem summaries stand out without stealing the selection
|
|
1674
|
+
band.
|
|
1675
|
+
"""
|
|
1676
|
+
width = max(20, int(width))
|
|
1677
|
+
gap = 2
|
|
1678
|
+
|
|
1679
|
+
def _kind(r: PickerRow) -> str:
|
|
1680
|
+
return str(r.get("kind", "item") or "item")
|
|
1681
|
+
|
|
1682
|
+
items = [r for r in rows if _kind(r) == "item"]
|
|
1683
|
+
num_w = max((len(f"{i + 1}. ") for i in range(len(items))), default=3) if numbered else 0
|
|
1684
|
+
label_w = max((len(str(r.get("label", ""))) for r in items), default=0)
|
|
1685
|
+
# Aligned description column = caret + number + widest label + gap, capped so a
|
|
1686
|
+
# very long label / narrow panel still leaves room for the description.
|
|
1687
|
+
desc_col = min(2 + num_w + label_w + gap, max(12, width - 12))
|
|
1688
|
+
desc_w = max(6, width - desc_col)
|
|
1689
|
+
label_room = max(1, desc_col - 2 - num_w - gap)
|
|
1690
|
+
|
|
1691
|
+
out: list[list[tuple[str, str]]] = []
|
|
1692
|
+
item_no = 0
|
|
1693
|
+
for i, row in enumerate(rows):
|
|
1694
|
+
kind = _kind(row)
|
|
1695
|
+
if kind == "spacer":
|
|
1696
|
+
out.append([("class:tui.picker", " " * width)])
|
|
1697
|
+
continue
|
|
1698
|
+
if kind == "header":
|
|
1699
|
+
text = str(row.get("label", "")).upper()
|
|
1700
|
+
if len(text) > width:
|
|
1701
|
+
text = text[: max(1, width - 1)] + "…"
|
|
1702
|
+
out.append(
|
|
1703
|
+
[
|
|
1704
|
+
("class:tui.picker.header", text),
|
|
1705
|
+
("class:tui.picker", " " * max(0, width - len(text))),
|
|
1706
|
+
]
|
|
1707
|
+
)
|
|
1708
|
+
continue
|
|
1709
|
+
item_no += 1
|
|
1710
|
+
sel = i == index
|
|
1711
|
+
base = "class:tui.picker.sel" if sel else "class:tui.picker"
|
|
1712
|
+
caret_style = "class:tui.picker.selcaret" if sel else "class:tui.picker.num"
|
|
1713
|
+
num_style = "class:tui.picker.selnum" if sel else "class:tui.picker.num"
|
|
1714
|
+
label_style = "class:tui.picker.sellabel" if sel else "class:tui.picker.label"
|
|
1715
|
+
if sel:
|
|
1716
|
+
desc_style = "class:tui.picker.seldesc"
|
|
1717
|
+
elif str(row.get("tone", "") or "") == "warn":
|
|
1718
|
+
desc_style = "class:tui.picker.warndesc"
|
|
1719
|
+
else:
|
|
1720
|
+
desc_style = "class:tui.picker.desc"
|
|
1721
|
+
tag_style = "class:tui.picker.seltag" if sel else "class:tui.picker.tag"
|
|
1722
|
+
|
|
1723
|
+
num = f"{item_no}. " if numbered else ""
|
|
1724
|
+
label = str(row.get("label", ""))
|
|
1725
|
+
if len(label) > label_room: # only trips on a very narrow panel
|
|
1726
|
+
label = label[: max(1, label_room - 1)] + "…"
|
|
1727
|
+
desc = str(row.get("description", "") or "")
|
|
1728
|
+
if "tag" in row:
|
|
1729
|
+
tag = str(row.get("tag") or "")
|
|
1730
|
+
else:
|
|
1731
|
+
tag = "(current)" if row.get("current") else ""
|
|
1732
|
+
|
|
1733
|
+
desc_lines = _wrap_line(desc, desc_w) if desc else [""]
|
|
1734
|
+
if len(desc_lines) > _PICKER_MAX_DESC_LINES:
|
|
1735
|
+
desc_lines = desc_lines[:_PICKER_MAX_DESC_LINES]
|
|
1736
|
+
last = desc_lines[-1][: max(1, desc_w - 1)].rstrip()
|
|
1737
|
+
desc_lines[-1] = last + "…"
|
|
1738
|
+
# The tag rides the last description line when it fits; otherwise the
|
|
1739
|
+
# description's last word is pulled down with it so the tag reads as a
|
|
1740
|
+
# natural wrap — never a lone, misaligned "(current)"/"(default)" line.
|
|
1741
|
+
tag_line = -1
|
|
1742
|
+
if tag:
|
|
1743
|
+
last = desc_lines[-1]
|
|
1744
|
+
if not last or len(last) + 1 + len(tag) <= desc_w:
|
|
1745
|
+
tag_line = len(desc_lines) - 1
|
|
1746
|
+
else:
|
|
1747
|
+
cut = last.rfind(" ")
|
|
1748
|
+
if 0 < cut and (len(last) - cut - 1) + 1 + len(tag) <= desc_w:
|
|
1749
|
+
desc_lines[-1] = last[:cut]
|
|
1750
|
+
desc_lines.append(last[cut + 1 :])
|
|
1751
|
+
else:
|
|
1752
|
+
desc_lines.append("")
|
|
1753
|
+
tag_line = len(desc_lines) - 1
|
|
1754
|
+
|
|
1755
|
+
for li, dline in enumerate(desc_lines):
|
|
1756
|
+
frags: list[tuple[str, str]] = []
|
|
1757
|
+
if li == 0:
|
|
1758
|
+
pad_label = " " * max(0, desc_col - (2 + len(num) + len(label)))
|
|
1759
|
+
frags.append((caret_style, "› " if sel else " "))
|
|
1760
|
+
frags.append((num_style, num))
|
|
1761
|
+
frags.append((label_style, label))
|
|
1762
|
+
frags.append((base, pad_label))
|
|
1763
|
+
else:
|
|
1764
|
+
frags.append((base, " " * desc_col))
|
|
1765
|
+
used = desc_col
|
|
1766
|
+
if dline:
|
|
1767
|
+
frags.append((desc_style, dline))
|
|
1768
|
+
used += len(dline)
|
|
1769
|
+
if li == tag_line and tag:
|
|
1770
|
+
tag_frag = f" {tag}" if dline else tag
|
|
1771
|
+
frags.append((tag_style, tag_frag))
|
|
1772
|
+
used += len(tag_frag)
|
|
1773
|
+
if used < width:
|
|
1774
|
+
frags.append((base, " " * (width - used)))
|
|
1775
|
+
out.append(frags)
|
|
1776
|
+
|
|
1777
|
+
out.append([("class:tui.picker", " " * width)])
|
|
1778
|
+
if hint:
|
|
1779
|
+
# Honour explicit line breaks, then wrap (never clip) so a long hint — e.g.
|
|
1780
|
+
# the "auto-delegate off" note — is never cut off at the right edge.
|
|
1781
|
+
for segment in str(hint).split("\n"):
|
|
1782
|
+
for hline in _wrap_line(segment, width):
|
|
1783
|
+
out.append(
|
|
1784
|
+
[
|
|
1785
|
+
("class:tui.picker.hint", hline),
|
|
1786
|
+
("class:tui.picker", " " * max(0, width - len(hline))),
|
|
1787
|
+
]
|
|
1788
|
+
)
|
|
1789
|
+
return out
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
# ------------------------------------------------------------- approval modal
|
|
1793
|
+
_APPROVE_ACTION_LABELS = {
|
|
1794
|
+
"shell_run": "Run command",
|
|
1795
|
+
"fs_write": "Write files",
|
|
1796
|
+
"fs_delete": "Delete files",
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
# Rows the approval modal always spends on chrome: a leading blank, the headline,
|
|
1801
|
+
# a blank, [body], a blank, the key legend, a trailing blank.
|
|
1802
|
+
_APPROVAL_FIXED_ROWS = 6
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def _approval_body_budget(screen_rows: int) -> int:
|
|
1806
|
+
"""Body lines that still leave the ``[y]/[a]/[n]`` legend on screen.
|
|
1807
|
+
|
|
1808
|
+
The modal deliberately does not scroll, so any row past the window height is
|
|
1809
|
+
simply unreachable — and the legend sits at the BOTTOM. Without a budget a
|
|
1810
|
+
long command silently pushes it out and the user is asked to consent to
|
|
1811
|
+
something with no visible way to answer.
|
|
1812
|
+
"""
|
|
1813
|
+
return max(2, int(screen_rows) - 4 - _FRAME_COLS - _APPROVAL_FIXED_ROWS)
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def _elide_middle(lines: list[str], budget: int) -> list[str]:
|
|
1817
|
+
"""Trim ``lines`` to ``budget`` rows, dropping the MIDDLE and saying so.
|
|
1818
|
+
|
|
1819
|
+
Both ends are kept on purpose: this renders shell commands for a consent
|
|
1820
|
+
prompt, where the part that matters is as likely to be at the end
|
|
1821
|
+
(``… && rm -rf /``) as at the start, so truncating the tail could hide
|
|
1822
|
+
exactly what the user needs to see before approving.
|
|
1823
|
+
"""
|
|
1824
|
+
if budget <= 0:
|
|
1825
|
+
return []
|
|
1826
|
+
if len(lines) <= budget:
|
|
1827
|
+
return lines
|
|
1828
|
+
hidden = len(lines) - budget + 1
|
|
1829
|
+
head = budget // 2
|
|
1830
|
+
tail = budget - 1 - head
|
|
1831
|
+
kept_tail = lines[len(lines) - tail :] if tail > 0 else []
|
|
1832
|
+
return [*lines[:head], f"… {hidden} more lines hidden …", *kept_tail]
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
def _approval_action_label(kind: str) -> str:
|
|
1836
|
+
if kind.startswith("custom_tool_run:"):
|
|
1837
|
+
return "Run custom tool"
|
|
1838
|
+
return _APPROVE_ACTION_LABELS.get(kind, f"Approve {kind}")
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _approval_is_destructive(kind: str, command: str) -> bool:
|
|
1842
|
+
if kind == "fs_delete":
|
|
1843
|
+
return True
|
|
1844
|
+
lowered = f" {command.lower()} "
|
|
1845
|
+
return any(token in lowered for token in (" rm ", "rm -", "force", "delete"))
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _render_approval_rows(
|
|
1849
|
+
request: Any, width: int, *, max_body_lines: int | None = None
|
|
1850
|
+
) -> list[list[tuple[str, str]]]:
|
|
1851
|
+
"""Render the approval modal body: a headline (amber, red if destructive), the
|
|
1852
|
+
target command/files (bright), the reason (dim), and the colour-coded keys.
|
|
1853
|
+
|
|
1854
|
+
``max_body_lines`` bounds the target/reason block so the key legend below it
|
|
1855
|
+
stays inside the (unscrollable) modal; see :func:`_approval_body_budget`.
|
|
1856
|
+
``None`` leaves the body unbounded.
|
|
1857
|
+
"""
|
|
1858
|
+
width = max(20, int(width))
|
|
1859
|
+
kind = str(getattr(request, "kind", "") or "")
|
|
1860
|
+
command = str(getattr(request, "command", "") or "")
|
|
1861
|
+
files = list(getattr(request, "files", []) or [])
|
|
1862
|
+
reason = str(getattr(request, "reason", "") or "")
|
|
1863
|
+
target = command or (", ".join(files) if files else kind)
|
|
1864
|
+
danger = _approval_is_destructive(kind, command)
|
|
1865
|
+
|
|
1866
|
+
base = "class:tui.approve"
|
|
1867
|
+
head_style = "class:tui.approve.head.danger" if danger else "class:tui.approve.head"
|
|
1868
|
+
indent = 2
|
|
1869
|
+
inner = max(8, width - indent * 2)
|
|
1870
|
+
|
|
1871
|
+
def _pad(fragments: list[tuple[str, str]], used: int) -> list[tuple[str, str]]:
|
|
1872
|
+
return [*fragments, (base, " " * max(0, width - used))]
|
|
1873
|
+
|
|
1874
|
+
out: list[list[tuple[str, str]]] = [[(base, " " * width)]]
|
|
1875
|
+
head = f"⚠ {_approval_action_label(kind)}"
|
|
1876
|
+
out.append(_pad([(base, " " * indent), (head_style, head)], indent + len(head)))
|
|
1877
|
+
out.append([(base, " " * width)])
|
|
1878
|
+
target_lines = _wrap_line(target, inner)
|
|
1879
|
+
reason_lines = _wrap_line(reason, inner) if reason else []
|
|
1880
|
+
if max_body_lines is not None:
|
|
1881
|
+
# The target is what is being consented to, so it keeps the lion's share;
|
|
1882
|
+
# the reason is capped at a third and elided first.
|
|
1883
|
+
if reason_lines:
|
|
1884
|
+
reason_lines = _elide_middle(reason_lines, max(1, max_body_lines // 3))
|
|
1885
|
+
target_lines = _elide_middle(target_lines, max(1, max_body_lines - len(reason_lines)))
|
|
1886
|
+
for chunk in target_lines:
|
|
1887
|
+
out.append(
|
|
1888
|
+
_pad([(base, " " * indent), ("class:tui.approve.target", chunk)], indent + len(chunk))
|
|
1889
|
+
)
|
|
1890
|
+
for chunk in reason_lines:
|
|
1891
|
+
out.append(
|
|
1892
|
+
_pad([(base, " " * indent), ("class:tui.approve.reason", chunk)], indent + len(chunk))
|
|
1893
|
+
)
|
|
1894
|
+
out.append([(base, " " * width)])
|
|
1895
|
+
opts: list[tuple[str, str]] = [
|
|
1896
|
+
(base, " " * indent),
|
|
1897
|
+
("class:tui.approve.key.yes", "[y]"),
|
|
1898
|
+
("class:tui.approve.optlabel", " yes "),
|
|
1899
|
+
("class:tui.approve.key.always", "[a]"),
|
|
1900
|
+
("class:tui.approve.optlabel", " always "),
|
|
1901
|
+
("class:tui.approve.key.no", "[n]"),
|
|
1902
|
+
("class:tui.approve.optlabel", " no"),
|
|
1903
|
+
]
|
|
1904
|
+
used = indent + len("[y] yes [a] always [n] no")
|
|
1905
|
+
out.append(_pad(opts, used))
|
|
1906
|
+
out.append([(base, " " * width)])
|
|
1907
|
+
return out
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
class _Cancellation:
|
|
1911
|
+
"""Minimal cancellation token understood by ``run_turn``.
|
|
1912
|
+
|
|
1913
|
+
``run_turn`` calls ``throw_if_cancelled`` between steps and per streamed token;
|
|
1914
|
+
raising ``KeyboardInterrupt`` mirrors the classic loop's interrupt semantics.
|
|
1915
|
+
|
|
1916
|
+
For the *initial* think-wait (the model has sent no tokens yet, so no callback
|
|
1917
|
+
fires) an optional abort callback lets the LLM client register its live HTTP
|
|
1918
|
+
response's ``close`` — :meth:`cancel` then closes the stream so the blocked
|
|
1919
|
+
read unwinds promptly instead of waiting for the first byte.
|
|
1920
|
+
"""
|
|
1921
|
+
|
|
1922
|
+
def __init__(self) -> None:
|
|
1923
|
+
self._cancelled = False
|
|
1924
|
+
self._abort: Callable[[], None] | None = None
|
|
1925
|
+
|
|
1926
|
+
def cancel(self) -> None:
|
|
1927
|
+
self._cancelled = True
|
|
1928
|
+
abort = self._abort
|
|
1929
|
+
if abort is not None:
|
|
1930
|
+
try:
|
|
1931
|
+
abort()
|
|
1932
|
+
except Exception:
|
|
1933
|
+
pass
|
|
1934
|
+
|
|
1935
|
+
def set_abort_callback(self, fn: Callable[[], None] | None) -> None:
|
|
1936
|
+
self._abort = fn
|
|
1937
|
+
|
|
1938
|
+
def clear_abort_callback(self) -> None:
|
|
1939
|
+
self._abort = None
|
|
1940
|
+
|
|
1941
|
+
@property
|
|
1942
|
+
def is_cancelled(self) -> bool:
|
|
1943
|
+
return self._cancelled
|
|
1944
|
+
|
|
1945
|
+
def throw_if_cancelled(self, reason: str = "cancelled_by_user") -> None:
|
|
1946
|
+
if self._cancelled:
|
|
1947
|
+
raise KeyboardInterrupt(reason)
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
class _PlaceholderProcessor(Processor):
|
|
1951
|
+
"""Show dim placeholder text while the input buffer is empty.
|
|
1952
|
+
|
|
1953
|
+
``text`` may be a string or a zero-arg callable resolved at render time (so
|
|
1954
|
+
the placeholder can change once a conversation is underway). The text carries
|
|
1955
|
+
a leading space so the cursor rests on a blank cell in front of it.
|
|
1956
|
+
"""
|
|
1957
|
+
|
|
1958
|
+
def __init__(
|
|
1959
|
+
self,
|
|
1960
|
+
text: str | Callable[[], str],
|
|
1961
|
+
style: str = "class:tui.placeholder",
|
|
1962
|
+
) -> None:
|
|
1963
|
+
self._text = text
|
|
1964
|
+
self._style = style
|
|
1965
|
+
|
|
1966
|
+
def _resolve(self) -> str:
|
|
1967
|
+
if callable(self._text):
|
|
1968
|
+
try:
|
|
1969
|
+
return self._text()
|
|
1970
|
+
except Exception:
|
|
1971
|
+
return ""
|
|
1972
|
+
return self._text
|
|
1973
|
+
|
|
1974
|
+
def apply_transformation(self, ti: Any) -> Transformation:
|
|
1975
|
+
if ti.lineno == 0 and not ti.document.text:
|
|
1976
|
+
return Transformation([(self._style, self._resolve())])
|
|
1977
|
+
return Transformation(ti.fragments)
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def _has_conversation(entries: list[tuple[str, str]]) -> bool:
|
|
1981
|
+
"""True once a real turn exists in the transcript.
|
|
1982
|
+
|
|
1983
|
+
Drives the welcome-landing ↔ chat split: the owl/wordmark landing stays up
|
|
1984
|
+
until the first *user/assistant* turn. Startup notices (the streaming-disabled
|
|
1985
|
+
warning, ``system``/``trace`` lines) are appended to the transcript at launch
|
|
1986
|
+
but must not count as a conversation, otherwise they would instantly dismiss
|
|
1987
|
+
the landing the moment the app opens.
|
|
1988
|
+
"""
|
|
1989
|
+
return any(role in ("user", "assistant") for role, _ in entries)
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
def _duplicate_assistant_indices(
|
|
1993
|
+
entries: list[tuple[str, str]], streaming_index: int | None
|
|
1994
|
+
) -> set[int]:
|
|
1995
|
+
"""Indices of completed assistant entries that verbatim-repeat the previous
|
|
1996
|
+
assistant block within the same turn (the tracker resets at each ``user``
|
|
1997
|
+
entry). A multi-step agent turn can re-stream the same answer across
|
|
1998
|
+
continuation/tool steps; the transcript keeps each streamed block, so without
|
|
1999
|
+
this the identical reply renders 2-3 times. The live (still-streaming) block
|
|
2000
|
+
is never collapsed, and the comparison is whitespace-insensitive and purely
|
|
2001
|
+
content-based — no model/provider-specific logic."""
|
|
2002
|
+
skip: set[int] = set()
|
|
2003
|
+
previous: str | None = None
|
|
2004
|
+
for index, (role, text) in enumerate(entries):
|
|
2005
|
+
if role == "user":
|
|
2006
|
+
previous = None
|
|
2007
|
+
continue
|
|
2008
|
+
if role != "assistant":
|
|
2009
|
+
continue
|
|
2010
|
+
normalized = " ".join(str(text or "").split())
|
|
2011
|
+
if not normalized:
|
|
2012
|
+
continue
|
|
2013
|
+
if streaming_index != index and previous == normalized:
|
|
2014
|
+
skip.add(index)
|
|
2015
|
+
continue
|
|
2016
|
+
previous = normalized
|
|
2017
|
+
return skip
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
def _status_line_fragments(
|
|
2021
|
+
*,
|
|
2022
|
+
running: bool,
|
|
2023
|
+
notice: str = "",
|
|
2024
|
+
selection_available: bool = False,
|
|
2025
|
+
input_pending: bool = False,
|
|
2026
|
+
queued_count: int = 0,
|
|
2027
|
+
staged_count: int = 0,
|
|
2028
|
+
) -> FormattedText:
|
|
2029
|
+
if running:
|
|
2030
|
+
if input_pending:
|
|
2031
|
+
return FormattedText(
|
|
2032
|
+
[("class:tui.status", " Enter to steer - Ctrl+Q to queue - Esc to interrupt")]
|
|
2033
|
+
)
|
|
2034
|
+
if queued_count > 0 or staged_count > 0:
|
|
2035
|
+
label = "message" if queued_count == 1 else "messages"
|
|
2036
|
+
staged_label = "command" if staged_count == 1 else "commands"
|
|
2037
|
+
parts = []
|
|
2038
|
+
if staged_count:
|
|
2039
|
+
parts.append(f"{staged_count} staged {staged_label}")
|
|
2040
|
+
if queued_count:
|
|
2041
|
+
parts.append(f"{queued_count} queued {label}")
|
|
2042
|
+
return FormattedText(
|
|
2043
|
+
[
|
|
2044
|
+
(
|
|
2045
|
+
"class:tui.status",
|
|
2046
|
+
f" {' - '.join(parts)} - Esc or Ctrl+C to interrupt",
|
|
2047
|
+
)
|
|
2048
|
+
]
|
|
2049
|
+
)
|
|
2050
|
+
return FormattedText([("class:tui.status", " Esc or Ctrl+C to interrupt")])
|
|
2051
|
+
if notice.strip():
|
|
2052
|
+
return FormattedText([("class:tui.status", f" {notice.strip()}")])
|
|
2053
|
+
if selection_available:
|
|
2054
|
+
return FormattedText([("class:tui.status", " ctrl+c to copy")])
|
|
2055
|
+
return FormattedText([])
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
def run_tui(
|
|
2059
|
+
state: TuiState,
|
|
2060
|
+
*,
|
|
2061
|
+
owl_color: bool = True,
|
|
2062
|
+
input: Any | None = None,
|
|
2063
|
+
output: Any | None = None,
|
|
2064
|
+
session_builder: Callable[[TuiSurface], Any] | None = None,
|
|
2065
|
+
on_turn_complete: Callable[[], None] | None = None,
|
|
2066
|
+
on_hud_refresh: Callable[[], None] | None = None,
|
|
2067
|
+
command_runner: Callable[[Any, str, int], tuple[str, str, str | None, dict[str, Any] | None]]
|
|
2068
|
+
| None = None,
|
|
2069
|
+
background_turns: bool = True,
|
|
2070
|
+
help_sections: list[HelpSection] | None = None,
|
|
2071
|
+
panel_providers: dict[str, Callable[[], dict[str, Any] | None]] | None = None,
|
|
2072
|
+
picker_providers: dict[str, Callable[[], dict[str, Any] | None]] | None = None,
|
|
2073
|
+
persona_cycle: Callable[[], list[tuple[str, str]] | None] | None = None,
|
|
2074
|
+
mode_cycle: Callable[[], list[tuple[str, str]] | None] | None = None,
|
|
2075
|
+
completer: Any | None = None,
|
|
2076
|
+
config_flow_factory: Callable[[], Any] | None = None,
|
|
2077
|
+
on_config_saved: Callable[[], ConfigReloadOutcome | bool | None] | None = None,
|
|
2078
|
+
unavailable_message: str | None = None,
|
|
2079
|
+
subscription_provider_id: str | None = None,
|
|
2080
|
+
open_config_on_start: bool = False,
|
|
2081
|
+
theme: TerminalTheme | None = None,
|
|
2082
|
+
) -> tuple[Any, list[tuple[str, str]]]:
|
|
2083
|
+
"""Run the full-screen TUI shell, optionally hosting a real agent session.
|
|
2084
|
+
|
|
2085
|
+
Returns ``(result, transcript)`` where ``result`` is the value passed to
|
|
2086
|
+
``app.exit`` (an exit word, or ``None`` for Ctrl-C/Ctrl-D) and ``transcript``
|
|
2087
|
+
is the list of ``(role, text)`` entries shown in the pane.
|
|
2088
|
+
|
|
2089
|
+
``session_builder`` receives the :class:`TuiSurface` and returns an object
|
|
2090
|
+
exposing ``run_turn(text, *, cancellation_token=...)`` (an ``AgentSession``).
|
|
2091
|
+
When omitted, submissions show a static stub reply (Phase 1 behaviour).
|
|
2092
|
+
|
|
2093
|
+
``on_turn_complete`` is invoked (worker thread) once after each turn so the
|
|
2094
|
+
caller can refresh live HUD fields (tokens/cost/context) on ``state``.
|
|
2095
|
+
``on_hud_refresh`` (optional) is the same idea but called *during* a turn at
|
|
2096
|
+
safe points (tool-end / message-done, throttled) so a long multi-step turn's
|
|
2097
|
+
footer numbers advance instead of staying frozen until the turn finishes; pass
|
|
2098
|
+
the same refresher used for ``on_turn_complete``.
|
|
2099
|
+
|
|
2100
|
+
``command_runner(session, text, width)`` routes a submission through the chat
|
|
2101
|
+
command handler and returns ``(action, output, instruction, run_kwargs)``:
|
|
2102
|
+
``action`` is ``"exit"`` | ``"handled"`` | ``"run"``; ``output`` is captured
|
|
2103
|
+
text to show in the transcript; for ``"run"`` the turn uses ``instruction``
|
|
2104
|
+
(defaulting to ``text``) and ``run_kwargs``. When omitted, slash commands are
|
|
2105
|
+
sent to the agent as plain messages.
|
|
2106
|
+
|
|
2107
|
+
``input``/``output`` are injectable so tests can drive the application with a
|
|
2108
|
+
pipe input and a dummy output. ``background_turns=False`` runs turns inline
|
|
2109
|
+
(used by tests for deterministic ordering).
|
|
2110
|
+
|
|
2111
|
+
``unavailable_message`` keeps the shell usable without constructing a model
|
|
2112
|
+
session: plain prompts show that blocker, while native commands such as
|
|
2113
|
+
``/help`` and ``/config`` continue to work. The native ``/login`` picker returns
|
|
2114
|
+
a sentinel result so the caller can perform browser login outside the terminal's
|
|
2115
|
+
alternate screen. ``open_config_on_start`` opens the configuration overlay on
|
|
2116
|
+
first paint (used after a successful login when model/reasoning selection is
|
|
2117
|
+
still required).
|
|
2118
|
+
|
|
2119
|
+
``panel_providers`` maps a command name (e.g. ``"/status"``) to a callable
|
|
2120
|
+
``provider(arg)`` returning a panel spec ``{"title", "hint"?, "sections"}``
|
|
2121
|
+
where ``sections`` is a list of :data:`PanelSection`, or ``None`` to decline.
|
|
2122
|
+
``arg`` is the text after the command word. When a spec is returned the command
|
|
2123
|
+
is TUI-native (like ``/help``): it opens the centered popup over the
|
|
2124
|
+
FloatContainer instead of routing through ``command_runner`` and never echoes
|
|
2125
|
+
into the transcript. When the provider returns ``None`` (e.g. ``/usage hud on``,
|
|
2126
|
+
``/config set …``) the submission falls through to ``picker_providers`` / the
|
|
2127
|
+
command runner so the typed form still applies.
|
|
2128
|
+
"""
|
|
2129
|
+
terminal_theme = theme or detect_terminal_theme()
|
|
2130
|
+
owl = load_owl_animation(color_enabled=owl_color)
|
|
2131
|
+
transcript = TuiTranscript()
|
|
2132
|
+
wheel_step_rows = _resolve_wheel_step_rows()
|
|
2133
|
+
|
|
2134
|
+
# ---- turn/run state (mutated across threads; guarded by simple flags) ----
|
|
2135
|
+
running: dict[str, bool] = {"on": False}
|
|
2136
|
+
pending_turns: list[str] = []
|
|
2137
|
+
pending_operations: list[_DeferredOperation] = []
|
|
2138
|
+
draining: dict[str, bool] = {"on": False}
|
|
2139
|
+
spinner: dict[str, int] = {"i": 0}
|
|
2140
|
+
cancel_box: dict[str, _Cancellation | None] = {"token": None}
|
|
2141
|
+
approval_box: dict[str, Any] = {"event": None, "decision": None, "request": None}
|
|
2142
|
+
worker_box: dict[str, threading.Thread | None] = {"thread": None}
|
|
2143
|
+
# Scroll state: ``follow`` pins to the latest line; once the user scrolls up
|
|
2144
|
+
# it sticks at ``offset`` (top visible row) until they return to the bottom
|
|
2145
|
+
# (or send a message).
|
|
2146
|
+
scroll: dict[str, Any] = {"follow": True, "offset": 0}
|
|
2147
|
+
# ``started`` stamps the running turn so the status line can show elapsed
|
|
2148
|
+
# time; ``reasoning_expanded`` is a local expansion override (Ctrl+R).
|
|
2149
|
+
run_box: dict[str, float] = {"started": 0.0}
|
|
2150
|
+
subagent_started_at: dict[str, float] = {}
|
|
2151
|
+
view: dict[str, bool] = {"reasoning_expanded": False, "planmeta_expanded": False}
|
|
2152
|
+
subagent_panel: dict[str, Any] = {
|
|
2153
|
+
"selected_run_id": "",
|
|
2154
|
+
"run_order": [],
|
|
2155
|
+
"cursors": {},
|
|
2156
|
+
"entries": {},
|
|
2157
|
+
"statuses": {},
|
|
2158
|
+
"lifecycles": {},
|
|
2159
|
+
"poll_failures": {},
|
|
2160
|
+
"last_poll": None,
|
|
2161
|
+
"tip_shown": False,
|
|
2162
|
+
}
|
|
2163
|
+
# Centered popup panel (the reusable /help recipe): ``on`` toggles the Float;
|
|
2164
|
+
# ``offset`` is the top visible row (driven through the same cursor-pin trick as
|
|
2165
|
+
# the transcript so a set scroll position sticks); ``title`` retitles the Frame;
|
|
2166
|
+
# ``builder`` is the active row-builder (``width -> rows``) so /help, /status and
|
|
2167
|
+
# every future panel share one overlay. ``n`` caches the rendered row count.
|
|
2168
|
+
# ``confirm`` (when set) is a command string a panel runs on Enter — the popup
|
|
2169
|
+
# then acts as a confirm dialog (e.g. the /forge intro: Enter enters Forge, Esc
|
|
2170
|
+
# cancels). It is None for ordinary read-only panels, where Enter just closes.
|
|
2171
|
+
help_box: dict[str, Any] = {
|
|
2172
|
+
"on": False,
|
|
2173
|
+
"offset": 0,
|
|
2174
|
+
"title": "Commands",
|
|
2175
|
+
"builder": None,
|
|
2176
|
+
"confirm": None,
|
|
2177
|
+
# Optional accent for the panel frame border: "forge" repaints it violet
|
|
2178
|
+
# (ties /show, /plan, the launch gate to the FORGE identity); None/"" is
|
|
2179
|
+
# the default green chrome shared by /help, /status, …
|
|
2180
|
+
"accent": None,
|
|
2181
|
+
}
|
|
2182
|
+
help_rows: dict[str, int] = {"n": 0}
|
|
2183
|
+
# Selectable picker popup (e.g. /mode): ``on`` toggles the Float, ``index`` is
|
|
2184
|
+
# the focused row, ``rows`` the option list, ``on_select`` the apply callback
|
|
2185
|
+
# (value -> list[(role, text)] messages to echo). Shares the dark popup chrome.
|
|
2186
|
+
picker_box: dict[str, Any] = {
|
|
2187
|
+
"on": False,
|
|
2188
|
+
"index": 0,
|
|
2189
|
+
"title": "Select",
|
|
2190
|
+
"hint": _PICKER_HINT,
|
|
2191
|
+
"rows": [],
|
|
2192
|
+
"on_select": None,
|
|
2193
|
+
}
|
|
2194
|
+
# In-TUI editor float (e.g. /plan edit on plan.json): ``on`` toggles the Float,
|
|
2195
|
+
# ``on_save`` validates+persists the buffer (returns ``(ok, message)``), and
|
|
2196
|
+
# ``status`` shows the last save error inline so the user can fix and re-save.
|
|
2197
|
+
editor_box: dict[str, Any] = {"on": False, "title": "Edit", "on_save": None, "status": ""}
|
|
2198
|
+
|
|
2199
|
+
def _safe_invalidate() -> None:
|
|
2200
|
+
try:
|
|
2201
|
+
get_app().invalidate()
|
|
2202
|
+
except Exception:
|
|
2203
|
+
pass
|
|
2204
|
+
|
|
2205
|
+
def _reload_saved_config() -> ConfigReloadOutcome:
|
|
2206
|
+
outcome = ConfigReloadOutcome.APPLIED
|
|
2207
|
+
if on_config_saved is not None:
|
|
2208
|
+
try:
|
|
2209
|
+
callback_result = on_config_saved()
|
|
2210
|
+
except Exception: # noqa: BLE001 - treat any reload error as a failure
|
|
2211
|
+
outcome = ConfigReloadOutcome.FAILED
|
|
2212
|
+
else:
|
|
2213
|
+
if isinstance(callback_result, ConfigReloadOutcome):
|
|
2214
|
+
outcome = callback_result
|
|
2215
|
+
elif callback_result is False:
|
|
2216
|
+
outcome = ConfigReloadOutcome.FAILED
|
|
2217
|
+
if outcome is ConfigReloadOutcome.FAILED:
|
|
2218
|
+
transcript.append(
|
|
2219
|
+
"error",
|
|
2220
|
+
"Configuration saved to disk, but the running session could not be "
|
|
2221
|
+
"reloaded - restart Alysis Code for the new settings to take effect.",
|
|
2222
|
+
)
|
|
2223
|
+
return outcome
|
|
2224
|
+
|
|
2225
|
+
transcript.set_invalidate(_safe_invalidate)
|
|
2226
|
+
|
|
2227
|
+
def _app_running() -> bool:
|
|
2228
|
+
try:
|
|
2229
|
+
return bool(getattr(get_app(), "is_running", False))
|
|
2230
|
+
except Exception:
|
|
2231
|
+
return False
|
|
2232
|
+
|
|
2233
|
+
# ---- approval (centered modal popup; only reached when approvals are set to ask) ----
|
|
2234
|
+
def _approval_ui(request: Any) -> Any:
|
|
2235
|
+
if not _app_running():
|
|
2236
|
+
return ApprovalDecision(allow=False)
|
|
2237
|
+
event = threading.Event()
|
|
2238
|
+
approval_box["event"] = event
|
|
2239
|
+
approval_box["decision"] = None
|
|
2240
|
+
approval_box["request"] = request # drives the approval Float
|
|
2241
|
+
_safe_invalidate()
|
|
2242
|
+
# Bounded wait so the worker leaves event.wait() if the app is torn down
|
|
2243
|
+
# (EOF/exception) before the user (or the exit hook) resolves it.
|
|
2244
|
+
while not event.wait(timeout=0.2):
|
|
2245
|
+
if not _app_running():
|
|
2246
|
+
approval_box["event"] = None
|
|
2247
|
+
approval_box["decision"] = None
|
|
2248
|
+
approval_box["request"] = None
|
|
2249
|
+
return ApprovalDecision(allow=False)
|
|
2250
|
+
decision = approval_box.get("decision") or ApprovalDecision(allow=False)
|
|
2251
|
+
approval_box["event"] = None
|
|
2252
|
+
approval_box["decision"] = None
|
|
2253
|
+
approval_box["request"] = None
|
|
2254
|
+
_safe_invalidate()
|
|
2255
|
+
return decision
|
|
2256
|
+
|
|
2257
|
+
def _resolve_approval(*, allow: bool, always: bool = False) -> None:
|
|
2258
|
+
event = approval_box.get("event")
|
|
2259
|
+
if event is None:
|
|
2260
|
+
return
|
|
2261
|
+
approval_box["decision"] = ApprovalDecision(allow=allow, allow_for_session=always)
|
|
2262
|
+
verdict = "allowed" if allow else "denied"
|
|
2263
|
+
transcript.append("trace", f"· approval {verdict}")
|
|
2264
|
+
event.set()
|
|
2265
|
+
_safe_invalidate()
|
|
2266
|
+
|
|
2267
|
+
_approval_pending = Condition(lambda: approval_box.get("event") is not None)
|
|
2268
|
+
|
|
2269
|
+
# ---- build the agent surface + session (eager; failure → caller falls back) ----
|
|
2270
|
+
surface: TuiSurface | None = None
|
|
2271
|
+
session: Any | None = None
|
|
2272
|
+
|
|
2273
|
+
def _set_active_subagent(name: str | None) -> None:
|
|
2274
|
+
# Called from worker/subagent threads on subagent start/end: pin (or
|
|
2275
|
+
# clear) the footer's "↪ <name>" badge so the user always knows a nested
|
|
2276
|
+
# agent is doing the work right now. Pinning a NAME requires a live turn
|
|
2277
|
+
# — an abandoned turn's parallel subagent threads outlive a soft
|
|
2278
|
+
# interrupt (their cancellation is thread-local to the turn worker) and
|
|
2279
|
+
# must not re-light the badge of an idle session. Clearing always wins.
|
|
2280
|
+
if name and not running["on"]:
|
|
2281
|
+
return
|
|
2282
|
+
if name:
|
|
2283
|
+
subagent_started_at.setdefault(str(name), time.monotonic())
|
|
2284
|
+
else:
|
|
2285
|
+
subagent_started_at.clear()
|
|
2286
|
+
state.active_subagent = str(name or "")
|
|
2287
|
+
_safe_invalidate()
|
|
2288
|
+
|
|
2289
|
+
def _set_active_subagents(names: tuple[str, ...]) -> None:
|
|
2290
|
+
if names and not running["on"]:
|
|
2291
|
+
return
|
|
2292
|
+
_sync_subagent_started_at(subagent_started_at, names, now=time.monotonic())
|
|
2293
|
+
state.active_subagents = tuple(str(name) for name in names if str(name))
|
|
2294
|
+
state.active_subagent = state.active_subagents[-1] if state.active_subagents else ""
|
|
2295
|
+
_safe_invalidate()
|
|
2296
|
+
|
|
2297
|
+
def _register_subagent_run(event: SubagentStartEvent) -> None:
|
|
2298
|
+
run_id = str(event.subagent_run_id or "").strip()
|
|
2299
|
+
if not run_id:
|
|
2300
|
+
return
|
|
2301
|
+
order = subagent_panel["run_order"]
|
|
2302
|
+
if run_id not in order:
|
|
2303
|
+
order.append(run_id)
|
|
2304
|
+
subagent_panel["cursors"].setdefault(run_id, 0)
|
|
2305
|
+
subagent_panel["entries"].setdefault(run_id, [])
|
|
2306
|
+
subagent_panel["lifecycles"][run_id] = {
|
|
2307
|
+
"run_id": run_id,
|
|
2308
|
+
"subagent": str(event.name),
|
|
2309
|
+
"label": str(event.label or ""),
|
|
2310
|
+
"state": "running",
|
|
2311
|
+
"collected": False,
|
|
2312
|
+
}
|
|
2313
|
+
subagent_panel["statuses"][run_id] = {
|
|
2314
|
+
"run_id": run_id,
|
|
2315
|
+
"subagent": str(event.name),
|
|
2316
|
+
"label": str(event.label or ""),
|
|
2317
|
+
"state": "running",
|
|
2318
|
+
"elapsed_ms": 0,
|
|
2319
|
+
"steps_completed": 0,
|
|
2320
|
+
"workspace_view": str(event.workspace_view or "shared"),
|
|
2321
|
+
}
|
|
2322
|
+
if not subagent_panel["tip_shown"] and not subagent_panel["selected_run_id"]:
|
|
2323
|
+
transcript.append("info", "tip: ctrl+n to follow subagent work")
|
|
2324
|
+
subagent_panel["tip_shown"] = True
|
|
2325
|
+
_safe_invalidate()
|
|
2326
|
+
|
|
2327
|
+
def _subagent_lifecycle_changed(payload: dict[str, Any]) -> None:
|
|
2328
|
+
run_id = str(payload.get("run_id") or "").strip()
|
|
2329
|
+
if not run_id:
|
|
2330
|
+
return
|
|
2331
|
+
subagent_panel["lifecycles"][run_id] = dict(payload)
|
|
2332
|
+
status = subagent_panel["statuses"].get(run_id)
|
|
2333
|
+
if isinstance(status, dict):
|
|
2334
|
+
status["state"] = str(payload.get("state") or status.get("state") or "")
|
|
2335
|
+
_safe_invalidate()
|
|
2336
|
+
|
|
2337
|
+
def _report_subagent_poll_failure(run_id: str, condition: str) -> None:
|
|
2338
|
+
transcript.append(
|
|
2339
|
+
"warn",
|
|
2340
|
+
f"Subagent panel refresh failed for run {run_id}: {condition}.",
|
|
2341
|
+
)
|
|
2342
|
+
_safe_invalidate()
|
|
2343
|
+
|
|
2344
|
+
def _child_scheduler_replaced(reason: str) -> None:
|
|
2345
|
+
had_history = bool(
|
|
2346
|
+
subagent_panel["selected_run_id"]
|
|
2347
|
+
or subagent_panel["run_order"]
|
|
2348
|
+
or any(
|
|
2349
|
+
subagent_panel[key]
|
|
2350
|
+
for key in (
|
|
2351
|
+
"cursors",
|
|
2352
|
+
"entries",
|
|
2353
|
+
"statuses",
|
|
2354
|
+
"lifecycles",
|
|
2355
|
+
"poll_failures",
|
|
2356
|
+
)
|
|
2357
|
+
)
|
|
2358
|
+
)
|
|
2359
|
+
subagent_panel["selected_run_id"] = ""
|
|
2360
|
+
subagent_panel["run_order"].clear()
|
|
2361
|
+
for key in (
|
|
2362
|
+
"cursors",
|
|
2363
|
+
"entries",
|
|
2364
|
+
"statuses",
|
|
2365
|
+
"lifecycles",
|
|
2366
|
+
"poll_failures",
|
|
2367
|
+
):
|
|
2368
|
+
subagent_panel[key].clear()
|
|
2369
|
+
subagent_panel["last_poll"] = None
|
|
2370
|
+
if had_history:
|
|
2371
|
+
detail = " ".join(str(reason or "settings applied").split())
|
|
2372
|
+
transcript.append("info", f"subagent history cleared: {detail}")
|
|
2373
|
+
_safe_invalidate()
|
|
2374
|
+
|
|
2375
|
+
if session_builder is not None:
|
|
2376
|
+
surface = TuiSurface(
|
|
2377
|
+
transcript,
|
|
2378
|
+
request_approval_ui=_approval_ui,
|
|
2379
|
+
# Mid-turn HUD refresher (throttled, worker-thread) so the footer's
|
|
2380
|
+
# context/tokens/cost advance while a long multi-step turn runs, not just
|
|
2381
|
+
# once it ends. Distinct from on_turn_complete so the end-of-turn hook
|
|
2382
|
+
# keeps its fire-once-per-turn contract.
|
|
2383
|
+
on_hud_refresh=on_hud_refresh,
|
|
2384
|
+
on_subagent_activity=_set_active_subagent,
|
|
2385
|
+
on_subagent_activities=_set_active_subagents,
|
|
2386
|
+
on_subagent_run_started=_register_subagent_run,
|
|
2387
|
+
)
|
|
2388
|
+
session = session_builder(surface)
|
|
2389
|
+
session.on_child_scheduler_replaced = _child_scheduler_replaced
|
|
2390
|
+
scheduler = getattr(session, "child_scheduler", None)
|
|
2391
|
+
set_lifecycle_listener = getattr(scheduler, "set_lifecycle_listener", None)
|
|
2392
|
+
if callable(set_lifecycle_listener):
|
|
2393
|
+
set_lifecycle_listener(_subagent_lifecycle_changed)
|
|
2394
|
+
# Seed the footer HUD from the freshly built session so the bottom-right
|
|
2395
|
+
# context/tokens/cost read accurately on the first paint, instead of a flat
|
|
2396
|
+
# "context 100%" until the first turn completes.
|
|
2397
|
+
if on_hud_refresh is not None:
|
|
2398
|
+
try:
|
|
2399
|
+
on_hud_refresh()
|
|
2400
|
+
except Exception:
|
|
2401
|
+
pass
|
|
2402
|
+
|
|
2403
|
+
# ---- welcome body (owl + wordmark + hint), shown until first message ----
|
|
2404
|
+
def _welcome_text() -> FormattedText:
|
|
2405
|
+
fragments: list[tuple[str, str]] = [("", "\n")]
|
|
2406
|
+
owl_ansi = owl.current_ansi()
|
|
2407
|
+
if owl_ansi is not None:
|
|
2408
|
+
fragments.extend(to_formatted_text(owl_ansi))
|
|
2409
|
+
fragments.append(("", "\n\n"))
|
|
2410
|
+
fragments.append(("class:tui.heading", _content.HEADING_TEXT))
|
|
2411
|
+
fragments.append(("class:tui.credit", " · " + _content.CREDIT_TEXT))
|
|
2412
|
+
fragments.append(("", "\n\n"))
|
|
2413
|
+
if state.connection_status:
|
|
2414
|
+
fragments.append(
|
|
2415
|
+
(
|
|
2416
|
+
"class:tui.footer.mode.warn",
|
|
2417
|
+
_model_access_setup_hint(subscription_provider_id),
|
|
2418
|
+
)
|
|
2419
|
+
)
|
|
2420
|
+
fragments.append(("", "\n\n"))
|
|
2421
|
+
fragments.append(("class:tui.hint", _content.HINT_TEXT))
|
|
2422
|
+
return FormattedText(fragments)
|
|
2423
|
+
|
|
2424
|
+
welcome_window = Window(
|
|
2425
|
+
FormattedTextControl(_welcome_text, focusable=False),
|
|
2426
|
+
align=WindowAlign.CENTER,
|
|
2427
|
+
)
|
|
2428
|
+
|
|
2429
|
+
# ---- transcript pane (pull-based; the worker only mutates the model) ----
|
|
2430
|
+
def _run_elapsed() -> int:
|
|
2431
|
+
return _activity_elapsed_seconds(
|
|
2432
|
+
turn_started=run_box["started"],
|
|
2433
|
+
active_subagent=state.active_subagent,
|
|
2434
|
+
subagent_started_at=subagent_started_at,
|
|
2435
|
+
now=time.monotonic(),
|
|
2436
|
+
)
|
|
2437
|
+
|
|
2438
|
+
_row_count = {"n": 0}
|
|
2439
|
+
selection: dict[str, Any] = {
|
|
2440
|
+
"anchor": None,
|
|
2441
|
+
"active": None,
|
|
2442
|
+
"dragging": False,
|
|
2443
|
+
"rows": [],
|
|
2444
|
+
"row_roles": [],
|
|
2445
|
+
"width": None,
|
|
2446
|
+
# True while a press that began on the plan-meta aside is in flight
|
|
2447
|
+
# (click on release = toggle; drag = select its text).
|
|
2448
|
+
"planmeta_press": False,
|
|
2449
|
+
}
|
|
2450
|
+
selection_notice: dict[str, Any] = {"text": "", "generation": 0}
|
|
2451
|
+
_role_styles = {
|
|
2452
|
+
"system": "class:tui.transcript.system",
|
|
2453
|
+
"trace": "class:tui.transcript.trace",
|
|
2454
|
+
"error": "class:tui.transcript.error",
|
|
2455
|
+
"warn": "class:tui.transcript.warn",
|
|
2456
|
+
"info": "class:tui.transcript.system",
|
|
2457
|
+
"subagent": "class:tui.transcript.subagent",
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
def _transcript_fragments() -> FormattedText:
|
|
2461
|
+
entries, status, streaming_index = transcript.snapshot()
|
|
2462
|
+
reasoning_index, reasoning_secs = transcript.reasoning_snapshot()
|
|
2463
|
+
# Use the transcript's content width — the terminal less its scrollbar
|
|
2464
|
+
# margin — so full-width user bands never overflow into a wrapped extra
|
|
2465
|
+
# row. Derived, not read back from render_info, which lags a frame behind
|
|
2466
|
+
# a resize and is absent entirely on the first one.
|
|
2467
|
+
width = _transcript_content_width_for(_current_width())
|
|
2468
|
+
if selection["width"] not in {None, width}:
|
|
2469
|
+
selection.update({"anchor": None, "active": None, "dragging": False})
|
|
2470
|
+
selection["width"] = width
|
|
2471
|
+
rendered_rows: list[list[tuple[str, str]]] = []
|
|
2472
|
+
rendered_row_roles: list[str] = []
|
|
2473
|
+
duplicate_assistant = _duplicate_assistant_indices(entries, streaming_index)
|
|
2474
|
+
future_copyable_entries = [False] * len(entries)
|
|
2475
|
+
seen_copyable_entry = False
|
|
2476
|
+
for entry_index in range(len(entries) - 1, -1, -1):
|
|
2477
|
+
future_copyable_entries[entry_index] = seen_copyable_entry
|
|
2478
|
+
entry_role = entries[entry_index][0]
|
|
2479
|
+
if entry_index not in duplicate_assistant and entry_role in _COPYABLE_TRANSCRIPT_ROLES:
|
|
2480
|
+
seen_copyable_entry = True
|
|
2481
|
+
for index, (role, text) in enumerate(entries):
|
|
2482
|
+
if index in duplicate_assistant:
|
|
2483
|
+
# A re-emitted verbatim copy of the same answer (multi-step turn):
|
|
2484
|
+
# render it once, drop the repeats.
|
|
2485
|
+
continue
|
|
2486
|
+
# A block builder may stamp per-row roles (planmeta lead vs wrap
|
|
2487
|
+
# continuation); everything else uses the entry role for all rows.
|
|
2488
|
+
block_roles: list[str] | None = None
|
|
2489
|
+
if role == "user":
|
|
2490
|
+
block = _user_band_rows(text, width) # full-width highlighted band
|
|
2491
|
+
elif role == "assistant":
|
|
2492
|
+
# Markdown-render once the block is complete; keep the still-
|
|
2493
|
+
# streaming block plain so a half-open code fence never flickers.
|
|
2494
|
+
done = streaming_index != index
|
|
2495
|
+
block = _assistant_rows(text, width, markdown=done, theme=terminal_theme)
|
|
2496
|
+
elif role == "planmeta":
|
|
2497
|
+
# Collapsible Forge planner meta / plan-reconciliation notes.
|
|
2498
|
+
# Rows carry per-row kinds: lead rows ("planmeta") own a
|
|
2499
|
+
# strippable glyph; wrap continuations ("planmetacont") start
|
|
2500
|
+
# with note content the copy path must keep verbatim.
|
|
2501
|
+
pairs = plan_meta_rows_with_kinds(text, width, expanded=view["planmeta_expanded"])
|
|
2502
|
+
block = [row for row, _kind in pairs]
|
|
2503
|
+
block_roles = [kind for _row, kind in pairs]
|
|
2504
|
+
elif role == "reasoning":
|
|
2505
|
+
is_live = reasoning_index == index
|
|
2506
|
+
expanded = view["reasoning_expanded"] or transcript.trace_level == "full"
|
|
2507
|
+
block = _reasoning_rows(
|
|
2508
|
+
text,
|
|
2509
|
+
width,
|
|
2510
|
+
live=is_live,
|
|
2511
|
+
secs=reasoning_secs.get(index, 0),
|
|
2512
|
+
expanded=expanded,
|
|
2513
|
+
spinner=_SPINNER_FRAMES[spinner["i"] % len(_SPINNER_FRAMES)] if is_live else "",
|
|
2514
|
+
elapsed=_run_elapsed() if is_live else 0,
|
|
2515
|
+
)
|
|
2516
|
+
else:
|
|
2517
|
+
style = _role_styles.get(role, "")
|
|
2518
|
+
block = _plain_role_rows(style, text, width)
|
|
2519
|
+
rendered_rows.extend(block)
|
|
2520
|
+
rendered_row_roles.extend(
|
|
2521
|
+
block_roles if block_roles is not None else [role] * len(block)
|
|
2522
|
+
)
|
|
2523
|
+
# Blank spacer between blocks for readability (so the thinking aside
|
|
2524
|
+
# is not glued to the highlighted question box above it).
|
|
2525
|
+
if index != len(entries) - 1:
|
|
2526
|
+
rendered_rows.append([])
|
|
2527
|
+
# Preserve a single semantic paragraph break between copyable
|
|
2528
|
+
# entries even when one or more non-copyable trace/reasoning
|
|
2529
|
+
# blocks are displayed between them.
|
|
2530
|
+
rendered_row_roles.append(
|
|
2531
|
+
"spacer"
|
|
2532
|
+
if role in _COPYABLE_TRANSCRIPT_ROLES and future_copyable_entries[index]
|
|
2533
|
+
else "chrome"
|
|
2534
|
+
)
|
|
2535
|
+
# Live Forge execution view (the task table + phase/spinner line) while
|
|
2536
|
+
# /execute plan runs the swarm — and it stays frozen as the final table once
|
|
2537
|
+
# the run completes (until the next submission clears it). It replaces the
|
|
2538
|
+
# generic activity indicator for the duration of a forge run.
|
|
2539
|
+
forge_view = transcript.forge_snapshot()
|
|
2540
|
+
if forge_view is not None:
|
|
2541
|
+
frame = _SPINNER_FRAMES[spinner["i"] % len(_SPINNER_FRAMES)]
|
|
2542
|
+
now_mono = time.monotonic()
|
|
2543
|
+
try:
|
|
2544
|
+
forge_elapsed = int(max(0, now_mono - float(forge_view["started"])))
|
|
2545
|
+
except Exception:
|
|
2546
|
+
forge_elapsed = 0
|
|
2547
|
+
# Per-task elapsed for the running rows (from the transcript's
|
|
2548
|
+
# active_map, stamped on the same monotonic clock the UI reads here).
|
|
2549
|
+
forge_task_elapsed: dict[str, int] = {}
|
|
2550
|
+
for _tid, _info in (forge_view.get("active_map") or {}).items():
|
|
2551
|
+
_started = _info.get("started") if isinstance(_info, dict) else None
|
|
2552
|
+
if _started is not None:
|
|
2553
|
+
try:
|
|
2554
|
+
forge_task_elapsed[str(_tid)] = int(max(0, now_mono - float(_started)))
|
|
2555
|
+
except (TypeError, ValueError):
|
|
2556
|
+
continue
|
|
2557
|
+
if entries:
|
|
2558
|
+
rendered_rows.append([])
|
|
2559
|
+
rendered_row_roles.append("chrome")
|
|
2560
|
+
forge_rows = _forge_view_rows(
|
|
2561
|
+
forge_view, width, frame, forge_elapsed, forge_task_elapsed
|
|
2562
|
+
)
|
|
2563
|
+
rendered_rows.extend(forge_rows)
|
|
2564
|
+
rendered_row_roles.extend(["forge"] * len(forge_rows))
|
|
2565
|
+
# Single live activity indicator under the content while the turn runs:
|
|
2566
|
+
# the model's current step — "thinking…" or the running tool name — with a
|
|
2567
|
+
# spinner + elapsed. Hidden while a reasoning block is itself live (it
|
|
2568
|
+
# carries its own header), while the answer streams, or while the forge view
|
|
2569
|
+
# is showing (it carries its own spinner). A blank line above gives it room.
|
|
2570
|
+
elif running["on"] and reasoning_index is None and streaming_index is None:
|
|
2571
|
+
frame = _SPINNER_FRAMES[spinner["i"] % len(_SPINNER_FRAMES)]
|
|
2572
|
+
label = status or "thinking…"
|
|
2573
|
+
if entries:
|
|
2574
|
+
rendered_rows.append([])
|
|
2575
|
+
rendered_row_roles.append("chrome")
|
|
2576
|
+
activity_rows = _activity_rows(
|
|
2577
|
+
frame,
|
|
2578
|
+
label,
|
|
2579
|
+
_run_elapsed(),
|
|
2580
|
+
elapsed_is_run_time=bool(state.active_subagent),
|
|
2581
|
+
)
|
|
2582
|
+
rendered_rows.extend(activity_rows)
|
|
2583
|
+
rendered_row_roles.extend(["activity"] * len(activity_rows))
|
|
2584
|
+
plain_rows = [fragment_list_to_text(row) for row in rendered_rows]
|
|
2585
|
+
selection["rows"] = plain_rows
|
|
2586
|
+
selection["row_roles"] = rendered_row_roles
|
|
2587
|
+
anchor = selection["anchor"]
|
|
2588
|
+
active = selection["active"]
|
|
2589
|
+
fragments: list[tuple[str, str]] = []
|
|
2590
|
+
for row_index, row in enumerate(rendered_rows):
|
|
2591
|
+
fragments.extend(
|
|
2592
|
+
_highlight_selection_in_row(
|
|
2593
|
+
row,
|
|
2594
|
+
row_index=row_index,
|
|
2595
|
+
anchor=anchor,
|
|
2596
|
+
active=active,
|
|
2597
|
+
)
|
|
2598
|
+
)
|
|
2599
|
+
fragments.append(("", "\n"))
|
|
2600
|
+
_row_count["n"] = len(rendered_rows)
|
|
2601
|
+
return FormattedText(fragments)
|
|
2602
|
+
|
|
2603
|
+
def _last_row() -> int:
|
|
2604
|
+
return max(0, _row_count["n"] - 1)
|
|
2605
|
+
|
|
2606
|
+
def _win_height() -> int:
|
|
2607
|
+
info = transcript_window.render_info
|
|
2608
|
+
return info.window_height if info is not None else 0
|
|
2609
|
+
|
|
2610
|
+
def _follow_top() -> int:
|
|
2611
|
+
# Top visible row when pinned to the bottom (so the last screen shows).
|
|
2612
|
+
return max(0, _last_row() - max(0, _win_height() - 1))
|
|
2613
|
+
|
|
2614
|
+
def _cursor_row() -> int:
|
|
2615
|
+
# We pin the Window's "cursor" to the TOP of the viewport (via the huge
|
|
2616
|
+
# bottom scroll-offset below), so vertical_scroll == this row. Following →
|
|
2617
|
+
# the bottom screen; otherwise the user's scroll position.
|
|
2618
|
+
if scroll["follow"]:
|
|
2619
|
+
return _follow_top()
|
|
2620
|
+
return max(0, min(int(scroll["offset"]), _last_row()))
|
|
2621
|
+
|
|
2622
|
+
def _scroll_move(delta: int) -> None:
|
|
2623
|
+
current = _follow_top() if scroll["follow"] else int(scroll["offset"])
|
|
2624
|
+
scroll["offset"], scroll["follow"] = _scroll_target(current, _follow_top(), delta)
|
|
2625
|
+
|
|
2626
|
+
def _wheel_scroll(direction: int) -> None:
|
|
2627
|
+
_scroll_move(direction * wheel_step_rows)
|
|
2628
|
+
_safe_invalidate()
|
|
2629
|
+
|
|
2630
|
+
def _show_selection_notice(message: str) -> None:
|
|
2631
|
+
selection_notice["generation"] += 1
|
|
2632
|
+
generation = selection_notice["generation"]
|
|
2633
|
+
selection_notice["text"] = message
|
|
2634
|
+
_safe_invalidate()
|
|
2635
|
+
|
|
2636
|
+
def _clear_notice() -> None:
|
|
2637
|
+
time.sleep(_COPY_NOTICE_SECONDS)
|
|
2638
|
+
if selection_notice["generation"] == generation:
|
|
2639
|
+
selection_notice["text"] = ""
|
|
2640
|
+
_safe_invalidate()
|
|
2641
|
+
|
|
2642
|
+
threading.Thread(target=_clear_notice, daemon=True).start()
|
|
2643
|
+
|
|
2644
|
+
def _copy_transcript_selection(selected: str) -> None:
|
|
2645
|
+
_show_selection_notice(_copy_selection_notice(selected))
|
|
2646
|
+
|
|
2647
|
+
def _current_transcript_selection() -> str:
|
|
2648
|
+
anchor = selection["anchor"]
|
|
2649
|
+
active = selection["active"]
|
|
2650
|
+
if anchor is None or active is None:
|
|
2651
|
+
return ""
|
|
2652
|
+
return _selected_text(
|
|
2653
|
+
selection["rows"],
|
|
2654
|
+
anchor,
|
|
2655
|
+
active,
|
|
2656
|
+
row_roles=selection["row_roles"],
|
|
2657
|
+
)
|
|
2658
|
+
|
|
2659
|
+
def _transcript_mouse_event(mouse_event: Any) -> Any:
|
|
2660
|
+
event_type = mouse_event.event_type
|
|
2661
|
+
point = Point(x=max(0, mouse_event.position.x), y=max(0, mouse_event.position.y))
|
|
2662
|
+
if event_type == MouseEventType.MOUSE_DOWN and mouse_event.button == MouseButton.LEFT:
|
|
2663
|
+
# Remember whether the press began on the collapsible plan-meta
|
|
2664
|
+
# aside: a plain click (press+release without moving) toggles it
|
|
2665
|
+
# on release, same as Ctrl+O, while a DRAG selects its text like
|
|
2666
|
+
# any other transcript block. The row_roles list is indexed by the
|
|
2667
|
+
# same coordinate the selection code uses, so this is
|
|
2668
|
+
# click-accurate wherever the block is scrolled to.
|
|
2669
|
+
selection["planmeta_press"] = _plan_meta_press_hit(
|
|
2670
|
+
selection.get("row_roles") or [], point.y
|
|
2671
|
+
)
|
|
2672
|
+
selection.update({"anchor": point, "active": point, "dragging": True})
|
|
2673
|
+
_safe_invalidate()
|
|
2674
|
+
return None
|
|
2675
|
+
if event_type == MouseEventType.MOUSE_MOVE and selection["dragging"]:
|
|
2676
|
+
selection["active"] = point
|
|
2677
|
+
_safe_invalidate()
|
|
2678
|
+
return None
|
|
2679
|
+
if event_type == MouseEventType.MOUSE_UP and selection["dragging"]:
|
|
2680
|
+
selection["active"] = point
|
|
2681
|
+
selection["dragging"] = False
|
|
2682
|
+
selected = _current_transcript_selection()
|
|
2683
|
+
if _plan_meta_click_toggles(
|
|
2684
|
+
pressed_planmeta=bool(selection.get("planmeta_press")),
|
|
2685
|
+
anchor=selection["anchor"],
|
|
2686
|
+
release=point,
|
|
2687
|
+
selected=selected,
|
|
2688
|
+
):
|
|
2689
|
+
view["planmeta_expanded"] = not view["planmeta_expanded"]
|
|
2690
|
+
if not selected:
|
|
2691
|
+
selection.update({"anchor": None, "active": None})
|
|
2692
|
+
selection["planmeta_press"] = False
|
|
2693
|
+
_safe_invalidate()
|
|
2694
|
+
return None
|
|
2695
|
+
return NotImplemented
|
|
2696
|
+
|
|
2697
|
+
transcript_window = Window(
|
|
2698
|
+
_ScrollableControl(
|
|
2699
|
+
_transcript_fragments,
|
|
2700
|
+
focusable=False,
|
|
2701
|
+
show_cursor=False,
|
|
2702
|
+
get_cursor_position=lambda: Point(x=0, y=_cursor_row()),
|
|
2703
|
+
on_scroll=_wheel_scroll,
|
|
2704
|
+
on_mouse_event=_transcript_mouse_event,
|
|
2705
|
+
),
|
|
2706
|
+
wrap_lines=True,
|
|
2707
|
+
# Huge bottom scroll-offset pins our "cursor" (the top visible row) to the
|
|
2708
|
+
# top of the viewport, so vertical_scroll tracks it 1:1 → exact, smooth
|
|
2709
|
+
# scrolling without cursor-visibility snap.
|
|
2710
|
+
scroll_offsets=ScrollOffsets(bottom=10**6),
|
|
2711
|
+
right_margins=[ScrollbarMargin(display_arrows=True)],
|
|
2712
|
+
)
|
|
2713
|
+
|
|
2714
|
+
def _scroll_page_rows() -> int:
|
|
2715
|
+
height = _win_height()
|
|
2716
|
+
return max(1, height - 1) if height > 0 else 10
|
|
2717
|
+
|
|
2718
|
+
# The welcome landing (owl + wordmark) stays up until the *conversation*
|
|
2719
|
+
# actually starts — the first user/assistant turn. Startup notices (the
|
|
2720
|
+
# streaming-disabled warning, system/trace lines) get appended to the
|
|
2721
|
+
# transcript too, but they must NOT dismiss the landing, so the welcome/chat
|
|
2722
|
+
# split keys on real turns rather than "any transcript entry". The notices
|
|
2723
|
+
# are still there and surface as soon as the first message is sent.
|
|
2724
|
+
def _conversation_started() -> bool:
|
|
2725
|
+
return _has_conversation(transcript.entries)
|
|
2726
|
+
|
|
2727
|
+
has_messages = Condition(_conversation_started)
|
|
2728
|
+
no_messages = Condition(lambda: not _conversation_started())
|
|
2729
|
+
|
|
2730
|
+
# ---- status / working line (between transcript and input) ----
|
|
2731
|
+
def _status_text() -> FormattedText:
|
|
2732
|
+
# All "agent is working" feedback (thinking + running tool, with the one
|
|
2733
|
+
# timer) now lives in the transcript under the question via the live
|
|
2734
|
+
# activity indicator. This line only carries the interrupt reminder while
|
|
2735
|
+
# busy, so there is never a second timer here.
|
|
2736
|
+
return _status_line_fragments(
|
|
2737
|
+
running=bool(running["on"]),
|
|
2738
|
+
notice=str(selection_notice["text"] or ""),
|
|
2739
|
+
selection_available=bool(_current_transcript_selection()),
|
|
2740
|
+
input_pending=bool(input_area.buffer.text.strip()),
|
|
2741
|
+
queued_count=len(pending_turns),
|
|
2742
|
+
staged_count=_pending_command_count(pending_operations),
|
|
2743
|
+
)
|
|
2744
|
+
|
|
2745
|
+
status_window = Window(FormattedTextControl(_status_text, focusable=False), height=1)
|
|
2746
|
+
|
|
2747
|
+
# ---- input box (multiline; Enter submits, Ctrl+J / Alt+Enter add a line) ----
|
|
2748
|
+
# Grows from one row up to a few as the user adds lines (so a pasted/multi-line
|
|
2749
|
+
# prompt stays visible); empty it is one row, keeping the welcome centering.
|
|
2750
|
+
def _placeholder_text() -> str:
|
|
2751
|
+
# Inside a Forge session the input is a plan editor — nudge the verbs.
|
|
2752
|
+
if getattr(state, "forge_mode", False):
|
|
2753
|
+
return " " + _content.INPUT_PLACEHOLDER_FORGE
|
|
2754
|
+
# Long greeting on the welcome screen; short follow-up once chatting.
|
|
2755
|
+
# (The Tab persona shortcut is advertised in the welcome HINT_TEXT,
|
|
2756
|
+
# outside the input box — a placeholder suffix wraps on narrow panes.)
|
|
2757
|
+
if _conversation_started():
|
|
2758
|
+
return " " + _content.INPUT_PLACEHOLDER_FOLLOWUP
|
|
2759
|
+
return " " + _content.INPUT_PLACEHOLDER
|
|
2760
|
+
|
|
2761
|
+
input_area = TextArea(
|
|
2762
|
+
height=D(min=1, max=8),
|
|
2763
|
+
multiline=True,
|
|
2764
|
+
wrap_lines=True,
|
|
2765
|
+
style="class:tui.input",
|
|
2766
|
+
completer=completer,
|
|
2767
|
+
complete_while_typing=True,
|
|
2768
|
+
input_processors=[
|
|
2769
|
+
_PlaceholderProcessor(_placeholder_text),
|
|
2770
|
+
BeforeInput("> ", style="class:tui.prompt"),
|
|
2771
|
+
],
|
|
2772
|
+
)
|
|
2773
|
+
welcome_visible = Condition(
|
|
2774
|
+
lambda: not _conversation_started() and input_area.buffer.complete_state is None
|
|
2775
|
+
)
|
|
2776
|
+
welcome_completion_blank = Condition(
|
|
2777
|
+
lambda: not _conversation_started() and input_area.buffer.complete_state is not None
|
|
2778
|
+
)
|
|
2779
|
+
|
|
2780
|
+
# ---- turn execution ----
|
|
2781
|
+
def _current_width() -> int:
|
|
2782
|
+
try:
|
|
2783
|
+
return get_app().output.get_size().columns
|
|
2784
|
+
except Exception:
|
|
2785
|
+
return 80
|
|
2786
|
+
|
|
2787
|
+
def _current_rows() -> int:
|
|
2788
|
+
try:
|
|
2789
|
+
return get_app().output.get_size().rows
|
|
2790
|
+
except Exception:
|
|
2791
|
+
return 24
|
|
2792
|
+
|
|
2793
|
+
def _schedule_on_ui_thread(callback: Callable[[], None]) -> None:
|
|
2794
|
+
"""Schedule a continuation on the prompt_toolkit event loop."""
|
|
2795
|
+
try:
|
|
2796
|
+
get_app().loop.call_soon_threadsafe(callback)
|
|
2797
|
+
except Exception: # noqa: BLE001 - inline tests have no live app loop
|
|
2798
|
+
callback()
|
|
2799
|
+
|
|
2800
|
+
def _run_turn_blocking(instruction: str, run_kwargs: dict[str, Any]) -> None:
|
|
2801
|
+
my_token = cancel_box["token"]
|
|
2802
|
+
# Tag this worker thread so the surface can drop its output if it gets
|
|
2803
|
+
# soft-interrupted (and keeps blocking on a slow model in the background).
|
|
2804
|
+
set_active_cancellation(my_token)
|
|
2805
|
+
cancelled = my_token is not None and getattr(my_token, "is_cancelled", False)
|
|
2806
|
+
# Commands that perform blocking work can provide a deferred callable.
|
|
2807
|
+
# Pop it so private TUI orchestration never leaks into
|
|
2808
|
+
# session.run_turn(**run_kwargs). The callable shares the normal turn's
|
|
2809
|
+
# worker, cancellation token, HUD refresh, and teardown lifecycle.
|
|
2810
|
+
deferred_execute = run_kwargs.pop("_deferred_execute", None) if run_kwargs else None
|
|
2811
|
+
try:
|
|
2812
|
+
if callable(deferred_execute):
|
|
2813
|
+
deferred_execute(my_token)
|
|
2814
|
+
else:
|
|
2815
|
+
from ...personas import (
|
|
2816
|
+
persona_overlay_messages,
|
|
2817
|
+
persona_overlay_user_messages,
|
|
2818
|
+
)
|
|
2819
|
+
|
|
2820
|
+
effective_kwargs = dict(run_kwargs or {})
|
|
2821
|
+
persona_overlays = persona_overlay_messages(
|
|
2822
|
+
cfg=getattr(session, "cfg", None),
|
|
2823
|
+
persona=getattr(session, "persona", "code"),
|
|
2824
|
+
registry=getattr(session, "persona_registry", None),
|
|
2825
|
+
)
|
|
2826
|
+
if persona_overlays:
|
|
2827
|
+
effective_kwargs["ephemeral_system_messages"] = (
|
|
2828
|
+
list(effective_kwargs.get("ephemeral_system_messages") or [])
|
|
2829
|
+
+ persona_overlays
|
|
2830
|
+
)
|
|
2831
|
+
persona_user_overlays = persona_overlay_user_messages(
|
|
2832
|
+
cfg=getattr(session, "cfg", None),
|
|
2833
|
+
persona=getattr(session, "persona", "code"),
|
|
2834
|
+
registry=getattr(session, "persona_registry", None),
|
|
2835
|
+
)
|
|
2836
|
+
if persona_user_overlays:
|
|
2837
|
+
effective_kwargs["ephemeral_user_messages"] = persona_user_overlays + list(
|
|
2838
|
+
effective_kwargs.get("ephemeral_user_messages") or []
|
|
2839
|
+
)
|
|
2840
|
+
session.run_turn(instruction, cancellation_token=my_token, **effective_kwargs)
|
|
2841
|
+
except KeyboardInterrupt:
|
|
2842
|
+
cancelled = True
|
|
2843
|
+
# A soft-interrupt already printed "Interrupted." from the key handler.
|
|
2844
|
+
if not (my_token is not None and my_token.is_cancelled):
|
|
2845
|
+
transcript.append("warn", "Interrupted.")
|
|
2846
|
+
except BaseException as exc: # noqa: BLE001 - surface any failure inline
|
|
2847
|
+
cancelled = my_token is not None and my_token.is_cancelled
|
|
2848
|
+
if not cancelled:
|
|
2849
|
+
if isinstance(exc, LLMError) or is_network_or_model_error(str(exc)):
|
|
2850
|
+
transcript.append("error", friendly_llm_error_message(exc))
|
|
2851
|
+
else:
|
|
2852
|
+
transcript.append("error", f"{type(exc).__name__}: {exc}")
|
|
2853
|
+
finally:
|
|
2854
|
+
# Only reset shared run state if we are still the active turn — a
|
|
2855
|
+
# soft-interrupt (or a newer turn) may have moved on while we were
|
|
2856
|
+
# blocked, and we must not stomp on its state when we finally unwind.
|
|
2857
|
+
if cancel_box.get("token") is my_token:
|
|
2858
|
+
transcript.set_status(None)
|
|
2859
|
+
# The turn is over — no subagent can still be active. Idempotent
|
|
2860
|
+
# (the end event normally cleared it); catches error unwinds.
|
|
2861
|
+
state.active_subagent = ""
|
|
2862
|
+
state.active_subagents = ()
|
|
2863
|
+
if on_turn_complete is not None:
|
|
2864
|
+
try:
|
|
2865
|
+
on_turn_complete()
|
|
2866
|
+
except Exception:
|
|
2867
|
+
pass
|
|
2868
|
+
# Flip run state OFF only AFTER the post-turn HUD refresh: while
|
|
2869
|
+
# running["on"] is True _submit blocks session-replacing commands,
|
|
2870
|
+
# so a UI-thread /resume (which swaps session.__dict__ in place)
|
|
2871
|
+
# cannot race this refresh's reads of the same session object.
|
|
2872
|
+
running["on"] = False
|
|
2873
|
+
cancel_box["token"] = None
|
|
2874
|
+
if background_turns:
|
|
2875
|
+
# Starting here would run on the worker thread and race an
|
|
2876
|
+
# interrupt that just cleared these flags. Marshal chaining
|
|
2877
|
+
# back to the UI loop, which owns pending_turns.
|
|
2878
|
+
_schedule_on_ui_thread(_start_next_pending_turn)
|
|
2879
|
+
_safe_invalidate()
|
|
2880
|
+
|
|
2881
|
+
def _clear_pending_work() -> tuple[int, int, bool]:
|
|
2882
|
+
"""Discard deferred work and return item, operation, and reload counts."""
|
|
2883
|
+
operation_count = len(pending_operations)
|
|
2884
|
+
reload_pending = any(
|
|
2885
|
+
operation.kind is _DeferredOperationKind.CONFIG_RELOAD
|
|
2886
|
+
for operation in pending_operations
|
|
2887
|
+
)
|
|
2888
|
+
discarded = len(pending_turns) + operation_count
|
|
2889
|
+
pending_operations.clear()
|
|
2890
|
+
pending_turns.clear()
|
|
2891
|
+
if session is not None:
|
|
2892
|
+
inbox = steer_inbox_for(session)
|
|
2893
|
+
if inbox is not None:
|
|
2894
|
+
discarded += len(inbox.drain())
|
|
2895
|
+
return discarded, operation_count, reload_pending
|
|
2896
|
+
|
|
2897
|
+
def _stop_pending_work_for_terminal_action() -> None:
|
|
2898
|
+
discarded, _operation_count, _reload_pending = _clear_pending_work()
|
|
2899
|
+
if discarded:
|
|
2900
|
+
label = "item" if discarded == 1 else "items"
|
|
2901
|
+
transcript.append(
|
|
2902
|
+
"warn",
|
|
2903
|
+
f"Discarded {discarded} pending {label} because this session is closing.",
|
|
2904
|
+
)
|
|
2905
|
+
_safe_invalidate()
|
|
2906
|
+
|
|
2907
|
+
def _start_next_pending_turn() -> bool:
|
|
2908
|
+
"""Apply ordered deferred operations, rescue steering, then start a turn."""
|
|
2909
|
+
if running["on"]:
|
|
2910
|
+
return False
|
|
2911
|
+
while pending_operations and not running["on"]:
|
|
2912
|
+
operation = pending_operations.pop(0)
|
|
2913
|
+
if operation.kind is _DeferredOperationKind.COMMAND:
|
|
2914
|
+
dispatch_outcome = _dispatch_command(
|
|
2915
|
+
operation.text,
|
|
2916
|
+
echo=False,
|
|
2917
|
+
allow_run=False,
|
|
2918
|
+
)
|
|
2919
|
+
if dispatch_outcome is _DispatchOutcome.EXIT:
|
|
2920
|
+
_stop_pending_work_for_terminal_action()
|
|
2921
|
+
return False
|
|
2922
|
+
continue
|
|
2923
|
+
reload_outcome = _reload_saved_config()
|
|
2924
|
+
if reload_outcome is ConfigReloadOutcome.RESTART:
|
|
2925
|
+
_stop_pending_work_for_terminal_action()
|
|
2926
|
+
return False
|
|
2927
|
+
if session is not None:
|
|
2928
|
+
inbox = steer_inbox_for(session)
|
|
2929
|
+
if inbox is not None:
|
|
2930
|
+
undelivered = inbox.drain()
|
|
2931
|
+
if undelivered:
|
|
2932
|
+
# Append late steering after earlier queued work. Keep both
|
|
2933
|
+
# queues bounded by returning overflow to the inbox; each
|
|
2934
|
+
# completed turn frees one slot and retries the handoff.
|
|
2935
|
+
capacity = max(0, MAX_PENDING_STEER_MESSAGES - len(pending_turns))
|
|
2936
|
+
pending_turns.extend(undelivered[:capacity])
|
|
2937
|
+
for deferred_text in undelivered[capacity:]:
|
|
2938
|
+
inbox.send(deferred_text)
|
|
2939
|
+
if not pending_turns or running["on"]:
|
|
2940
|
+
return False
|
|
2941
|
+
# Peek first and consume only after _begin_run accepts it. Popping before
|
|
2942
|
+
# the concurrency guard would lose the user's queued message on refusal.
|
|
2943
|
+
if _begin_run(pending_turns[0], {}, notice="Running queued message."):
|
|
2944
|
+
pending_turns.pop(0)
|
|
2945
|
+
return True
|
|
2946
|
+
return False
|
|
2947
|
+
|
|
2948
|
+
def _begin_run(
|
|
2949
|
+
instruction: str,
|
|
2950
|
+
run_kwargs: dict[str, Any],
|
|
2951
|
+
*,
|
|
2952
|
+
notice: str = "",
|
|
2953
|
+
) -> bool:
|
|
2954
|
+
if running["on"]:
|
|
2955
|
+
transcript.append("warn", "A turn is already running - Esc to interrupt it first.")
|
|
2956
|
+
_safe_invalidate()
|
|
2957
|
+
return False
|
|
2958
|
+
if notice:
|
|
2959
|
+
transcript.append("system", notice)
|
|
2960
|
+
running["on"] = True
|
|
2961
|
+
subagent_panel["tip_shown"] = False
|
|
2962
|
+
cancel_box["token"] = _Cancellation()
|
|
2963
|
+
run_box["started"] = time.monotonic()
|
|
2964
|
+
# No "Thinking…" footer status — the transient thinking indicator now
|
|
2965
|
+
# renders under the question (see _transcript_fragments).
|
|
2966
|
+
transcript.set_status(None)
|
|
2967
|
+
_safe_invalidate()
|
|
2968
|
+
if background_turns:
|
|
2969
|
+
worker = threading.Thread(
|
|
2970
|
+
target=_run_turn_blocking, args=(instruction, run_kwargs), daemon=True
|
|
2971
|
+
)
|
|
2972
|
+
worker_box["thread"] = worker
|
|
2973
|
+
worker.start()
|
|
2974
|
+
else:
|
|
2975
|
+
_run_turn_blocking(instruction, run_kwargs)
|
|
2976
|
+
# Inline tests run turns synchronously. Drain iteratively behind a
|
|
2977
|
+
# re-entrancy guard so N queued messages do not recurse N frame sets.
|
|
2978
|
+
if not draining["on"]:
|
|
2979
|
+
draining["on"] = True
|
|
2980
|
+
try:
|
|
2981
|
+
while (pending_operations or pending_turns) and not running["on"]:
|
|
2982
|
+
if not _start_next_pending_turn():
|
|
2983
|
+
break
|
|
2984
|
+
finally:
|
|
2985
|
+
draining["on"] = False
|
|
2986
|
+
return True
|
|
2987
|
+
|
|
2988
|
+
def _soft_interrupt() -> None:
|
|
2989
|
+
# Respond to Ctrl+C / Esc *immediately*: free the UI and show "Interrupted."
|
|
2990
|
+
# now, without waiting for the (possibly still-blocked) worker to unwind.
|
|
2991
|
+
# The cancel flips the token — the surface then drops that worker's output,
|
|
2992
|
+
# it closes any live HTTP stream, and the worker exits at its next checkpoint
|
|
2993
|
+
# (its finally no-ops since the active token has changed).
|
|
2994
|
+
token = cancel_box.get("token")
|
|
2995
|
+
if token is None and not running["on"]:
|
|
2996
|
+
return
|
|
2997
|
+
if token is not None:
|
|
2998
|
+
token.cancel()
|
|
2999
|
+
pending = approval_box.get("event")
|
|
3000
|
+
if pending is not None:
|
|
3001
|
+
approval_box["decision"] = ApprovalDecision(allow=False)
|
|
3002
|
+
pending.set()
|
|
3003
|
+
surface = getattr(session, "surface", None)
|
|
3004
|
+
interrupt_forge = getattr(surface, "interrupt_forge", None)
|
|
3005
|
+
if callable(interrupt_forge):
|
|
3006
|
+
try:
|
|
3007
|
+
interrupt_forge()
|
|
3008
|
+
except Exception:
|
|
3009
|
+
pass
|
|
3010
|
+
transcript.append("warn", "Interrupted.")
|
|
3011
|
+
discarded, discarded_operations, discarded_reload = _clear_pending_work()
|
|
3012
|
+
if discarded:
|
|
3013
|
+
if discarded_operations:
|
|
3014
|
+
label = "item" if discarded == 1 else "items"
|
|
3015
|
+
else:
|
|
3016
|
+
label = "message" if discarded == 1 else "messages"
|
|
3017
|
+
transcript.append("warn", f"Discarded {discarded} pending {label}.")
|
|
3018
|
+
if discarded_reload:
|
|
3019
|
+
transcript.append(
|
|
3020
|
+
"warn",
|
|
3021
|
+
"Configuration remains saved on disk, but this session was not reloaded. "
|
|
3022
|
+
"Restart Alysis Code for the saved settings to take effect.",
|
|
3023
|
+
)
|
|
3024
|
+
running["on"] = False
|
|
3025
|
+
cancel_box["token"] = None
|
|
3026
|
+
transcript.set_status(None)
|
|
3027
|
+
# The abandoned worker may never deliver its subagent-end event; drop the
|
|
3028
|
+
# badge now so the footer cannot claim a dead subagent is still working.
|
|
3029
|
+
# Also forget the surface's live-subagent stack: the abandoned runs' late
|
|
3030
|
+
# end events then find no matching entry and are dropped whole, instead
|
|
3031
|
+
# of popping (or re-pinning) whatever the NEXT turn is running.
|
|
3032
|
+
state.active_subagent = ""
|
|
3033
|
+
state.active_subagents = ()
|
|
3034
|
+
clear_subagents = getattr(surface, "clear_subagent_activity", None)
|
|
3035
|
+
if callable(clear_subagents):
|
|
3036
|
+
try:
|
|
3037
|
+
clear_subagents()
|
|
3038
|
+
except Exception:
|
|
3039
|
+
pass
|
|
3040
|
+
scroll["follow"] = True
|
|
3041
|
+
_safe_invalidate()
|
|
3042
|
+
|
|
3043
|
+
def _dispatch_command(
|
|
3044
|
+
text: str, *, echo: bool = True, allow_run: bool = True
|
|
3045
|
+
) -> _DispatchOutcome:
|
|
3046
|
+
"""Route ``text`` straight through the chat command runner.
|
|
3047
|
+
|
|
3048
|
+
This is the real path of :func:`_submit` (echo the user line, run the
|
|
3049
|
+
command handler, then exit / begin a turn / show captured output). It is
|
|
3050
|
+
factored out so a confirm popup can run its command on Enter — bypassing
|
|
3051
|
+
the /help, panel and picker interceptions — e.g. the /forge intro popup
|
|
3052
|
+
whose Enter enters the Forge session. No-ops without a session + runner.
|
|
3053
|
+
"""
|
|
3054
|
+
if session is None or command_runner is None:
|
|
3055
|
+
return _DispatchOutcome.CONTINUE
|
|
3056
|
+
scroll["follow"] = True
|
|
3057
|
+
if echo:
|
|
3058
|
+
input_area.buffer.reset()
|
|
3059
|
+
transcript.append_user(text)
|
|
3060
|
+
try:
|
|
3061
|
+
# The command's captured Rich output is appended to the transcript and
|
|
3062
|
+
# re-wrapped at the transcript's content width, so render it at that
|
|
3063
|
+
# width too — at the raw terminal width every full-width line is one
|
|
3064
|
+
# column too long and splits, tearing panel borders apart.
|
|
3065
|
+
action, output, instruction, run_kwargs = command_runner(
|
|
3066
|
+
session, text, _transcript_content_width_for(_current_width())
|
|
3067
|
+
)
|
|
3068
|
+
except Exception as exc: # noqa: BLE001 - never crash the UI on a command
|
|
3069
|
+
transcript.append("error", f"Command failed: {exc}")
|
|
3070
|
+
_safe_invalidate()
|
|
3071
|
+
return _DispatchOutcome.CONTINUE
|
|
3072
|
+
if output:
|
|
3073
|
+
transcript.append("system", output)
|
|
3074
|
+
if action == "exit":
|
|
3075
|
+
get_app().exit(result=text.strip())
|
|
3076
|
+
return _DispatchOutcome.EXIT
|
|
3077
|
+
if action == "run":
|
|
3078
|
+
if not allow_run:
|
|
3079
|
+
transcript.append(
|
|
3080
|
+
"warn",
|
|
3081
|
+
f"Deferred command was rejected because it tried to start a turn: {text}",
|
|
3082
|
+
)
|
|
3083
|
+
_safe_invalidate()
|
|
3084
|
+
return _DispatchOutcome.CONTINUE
|
|
3085
|
+
_begin_run(instruction if instruction is not None else text, run_kwargs or {})
|
|
3086
|
+
return _DispatchOutcome.CONTINUE
|
|
3087
|
+
_safe_invalidate() # "handled" — output already shown
|
|
3088
|
+
return _DispatchOutcome.CONTINUE
|
|
3089
|
+
|
|
3090
|
+
def _defer_mid_turn_command(text: str) -> None:
|
|
3091
|
+
if not _stage_pending_command(pending_operations, text):
|
|
3092
|
+
transcript.append(
|
|
3093
|
+
"warn",
|
|
3094
|
+
f"Deferred command queue is full ({_MAX_PENDING_COMMANDS}) - "
|
|
3095
|
+
"let the current turn finish before adding more.",
|
|
3096
|
+
)
|
|
3097
|
+
_safe_invalidate()
|
|
3098
|
+
return
|
|
3099
|
+
input_area.buffer.reset()
|
|
3100
|
+
scroll["follow"] = True
|
|
3101
|
+
transcript.append_user(text)
|
|
3102
|
+
transcript.append("system", defer_message(text))
|
|
3103
|
+
_safe_invalidate()
|
|
3104
|
+
|
|
3105
|
+
def _deliver_mid_turn_message(text: str, *, queue: bool) -> None:
|
|
3106
|
+
buff = input_area.buffer
|
|
3107
|
+
stripped = text.strip()
|
|
3108
|
+
if not stripped:
|
|
3109
|
+
return
|
|
3110
|
+
scroll["follow"] = True
|
|
3111
|
+
inbox = steer_inbox_for(session, create=True) if session is not None else None
|
|
3112
|
+
if queue or inbox is None:
|
|
3113
|
+
if len(pending_turns) >= MAX_PENDING_STEER_MESSAGES:
|
|
3114
|
+
transcript.append(
|
|
3115
|
+
"warn",
|
|
3116
|
+
f"Queue is full ({MAX_PENDING_STEER_MESSAGES}) - "
|
|
3117
|
+
"let queued work run before adding more.",
|
|
3118
|
+
)
|
|
3119
|
+
_safe_invalidate()
|
|
3120
|
+
return
|
|
3121
|
+
pending_turns.append(stripped)
|
|
3122
|
+
buff.reset()
|
|
3123
|
+
transcript.append_user(stripped)
|
|
3124
|
+
transcript.append(
|
|
3125
|
+
"system",
|
|
3126
|
+
f"Queued - runs when this turn finishes ({len(pending_turns)} waiting).",
|
|
3127
|
+
)
|
|
3128
|
+
_safe_invalidate()
|
|
3129
|
+
return
|
|
3130
|
+
|
|
3131
|
+
dropped_before = inbox.dropped_count()
|
|
3132
|
+
delivered = inbox.send(stripped)
|
|
3133
|
+
buff.reset()
|
|
3134
|
+
transcript.append_user(delivered)
|
|
3135
|
+
newly_dropped = inbox.dropped_count() - dropped_before
|
|
3136
|
+
if newly_dropped > 0:
|
|
3137
|
+
label = "message" if newly_dropped == 1 else "messages"
|
|
3138
|
+
transcript.append(
|
|
3139
|
+
"warn",
|
|
3140
|
+
f"Dropped {newly_dropped} older undelivered {label} - "
|
|
3141
|
+
"too many arrived before the next step.",
|
|
3142
|
+
)
|
|
3143
|
+
transcript.append("system", "Sent to the running turn - lands at its next step.")
|
|
3144
|
+
_safe_invalidate()
|
|
3145
|
+
|
|
3146
|
+
def _submit(*, queue_instead: bool = False) -> None:
|
|
3147
|
+
buff = input_area.buffer
|
|
3148
|
+
text = buff.text
|
|
3149
|
+
stripped = text.strip()
|
|
3150
|
+
if not stripped:
|
|
3151
|
+
return
|
|
3152
|
+
if approval_box.get("event") is not None:
|
|
3153
|
+
return
|
|
3154
|
+
if running["on"]:
|
|
3155
|
+
if config_overlay is not None and stripped.lower() == "/config":
|
|
3156
|
+
buff.reset()
|
|
3157
|
+
config_overlay.open()
|
|
3158
|
+
return
|
|
3159
|
+
action = classify_mid_turn(stripped)
|
|
3160
|
+
if action is MidTurnAction.BLOCK:
|
|
3161
|
+
transcript.append("warn", block_message(stripped))
|
|
3162
|
+
_safe_invalidate()
|
|
3163
|
+
return
|
|
3164
|
+
if action is MidTurnAction.DEFER:
|
|
3165
|
+
_defer_mid_turn_command(text)
|
|
3166
|
+
return
|
|
3167
|
+
if action is MidTurnAction.MESSAGE:
|
|
3168
|
+
_deliver_mid_turn_message(text, queue=queue_instead)
|
|
3169
|
+
return
|
|
3170
|
+
# Returning to the live tail whenever the user sends something.
|
|
3171
|
+
scroll["follow"] = True
|
|
3172
|
+
|
|
3173
|
+
# /help is TUI-native: open the centered command popup instead of routing
|
|
3174
|
+
# it anywhere (works with or without a session).
|
|
3175
|
+
if stripped.lower() == "/help":
|
|
3176
|
+
buff.reset()
|
|
3177
|
+
_open_help()
|
|
3178
|
+
return
|
|
3179
|
+
|
|
3180
|
+
login_parts = stripped.split(maxsplit=1)
|
|
3181
|
+
if len(login_parts) == 2 and login_parts[0].lower() == "/login":
|
|
3182
|
+
buff.reset()
|
|
3183
|
+
get_app().exit(result=("login_connection", login_parts[1].strip()))
|
|
3184
|
+
return
|
|
3185
|
+
|
|
3186
|
+
# /clear is TUI-native (the classic command clears the console screen).
|
|
3187
|
+
if session is not None and stripped.lower() == "/clear":
|
|
3188
|
+
transcript.clear()
|
|
3189
|
+
scroll["offset"] = 0
|
|
3190
|
+
buff.reset()
|
|
3191
|
+
_safe_invalidate()
|
|
3192
|
+
return
|
|
3193
|
+
|
|
3194
|
+
# /subagents is a read-only TUI window over the scheduler's live child
|
|
3195
|
+
# set. Selection opens the existing live panel; an empty set stays inline.
|
|
3196
|
+
if stripped.lower() == "/subagents":
|
|
3197
|
+
buff.reset()
|
|
3198
|
+
scheduler = getattr(session, "child_scheduler", None)
|
|
3199
|
+
if scheduler is None:
|
|
3200
|
+
transcript.append("system", "no active subagents")
|
|
3201
|
+
_safe_invalidate()
|
|
3202
|
+
return
|
|
3203
|
+
try:
|
|
3204
|
+
spec = _subagents_picker_spec(scheduler, subagent_panel)
|
|
3205
|
+
except Exception as exc: # noqa: BLE001 - inspection must not crash the UI
|
|
3206
|
+
transcript.append("error", f"/subagents failed: {exc}")
|
|
3207
|
+
_safe_invalidate()
|
|
3208
|
+
return
|
|
3209
|
+
if spec is None:
|
|
3210
|
+
transcript.append("system", "no active subagents")
|
|
3211
|
+
_safe_invalidate()
|
|
3212
|
+
return
|
|
3213
|
+
_open_picker(spec)
|
|
3214
|
+
return
|
|
3215
|
+
|
|
3216
|
+
# /config is TUI-native: bare `/config` opens the full configuration menu
|
|
3217
|
+
# overlay (the classic interactive menu can't run in the alt-screen). The
|
|
3218
|
+
# argument forms (`/config show|list|set|clear|…`) fall through to the panel
|
|
3219
|
+
# provider / command runner so the typed forms still apply.
|
|
3220
|
+
if config_overlay is not None and stripped.lower() == "/config":
|
|
3221
|
+
buff.reset()
|
|
3222
|
+
config_overlay.open()
|
|
3223
|
+
return
|
|
3224
|
+
|
|
3225
|
+
# Panel commands (e.g. /status, /usage) are TUI-native too: the first token
|
|
3226
|
+
# names a provider, called with the remaining argument, that returns a
|
|
3227
|
+
# {title, hint?, sections} spec to open the centered popup instead of routing
|
|
3228
|
+
# to the command runner (no transcript echo). Returning None means "this
|
|
3229
|
+
# invocation isn't a panel" (e.g. "/usage hud on", "/config set …") — we fall
|
|
3230
|
+
# through to the picker / command runner so the typed form still applies.
|
|
3231
|
+
if panel_providers:
|
|
3232
|
+
parts = stripped.split(maxsplit=1)
|
|
3233
|
+
name = parts[0].lower()
|
|
3234
|
+
provider = panel_providers.get(name)
|
|
3235
|
+
if provider is not None:
|
|
3236
|
+
cmd_arg = parts[1].strip() if len(parts) > 1 else ""
|
|
3237
|
+
try:
|
|
3238
|
+
spec = provider(cmd_arg)
|
|
3239
|
+
except Exception as exc: # noqa: BLE001 - never crash the UI on a panel
|
|
3240
|
+
buff.reset()
|
|
3241
|
+
transcript.append("error", f"{name} failed: {exc}")
|
|
3242
|
+
_safe_invalidate()
|
|
3243
|
+
return
|
|
3244
|
+
if spec is not None:
|
|
3245
|
+
buff.reset()
|
|
3246
|
+
hint = spec.get("hint") or _HELP_HINT
|
|
3247
|
+
title = spec.get("title") or name
|
|
3248
|
+
editor_spec = spec.get("editor")
|
|
3249
|
+
picker_spec = spec.get("picker")
|
|
3250
|
+
body = spec.get("body")
|
|
3251
|
+
# An optional confirm command turns the panel into a confirm
|
|
3252
|
+
# dialog: Enter runs it (e.g. the /forge intro enters Forge),
|
|
3253
|
+
# Esc/q cancels. None for ordinary read-only panels.
|
|
3254
|
+
confirm = spec.get("confirm")
|
|
3255
|
+
on_confirm = spec.get("on_confirm")
|
|
3256
|
+
# Optional violet frame accent for Forge panels (default None).
|
|
3257
|
+
accent = spec.get("accent")
|
|
3258
|
+
if picker_spec is not None:
|
|
3259
|
+
# A command that opens a selectable popup directly (e.g. the
|
|
3260
|
+
# forge /execute launch gate). Distinct from picker_providers
|
|
3261
|
+
# (which only fire for the bare, no-argument form).
|
|
3262
|
+
_open_picker(picker_spec)
|
|
3263
|
+
return
|
|
3264
|
+
if editor_spec is not None:
|
|
3265
|
+
# An editable document (e.g. /plan edit) opens the editor
|
|
3266
|
+
# float instead of a read-only panel.
|
|
3267
|
+
_open_editor(editor_spec)
|
|
3268
|
+
return
|
|
3269
|
+
if body is not None:
|
|
3270
|
+
# A document panel (e.g. /plan markdown → PLAN.md): render the
|
|
3271
|
+
# raw text (markdown-aware) instead of key/value sections.
|
|
3272
|
+
_open_panel(
|
|
3273
|
+
title,
|
|
3274
|
+
lambda width, _b=body, _h=hint: _render_doc_panel_rows(
|
|
3275
|
+
_b,
|
|
3276
|
+
width,
|
|
3277
|
+
_h,
|
|
3278
|
+
theme=terminal_theme,
|
|
3279
|
+
),
|
|
3280
|
+
confirm=confirm,
|
|
3281
|
+
on_confirm=on_confirm,
|
|
3282
|
+
accent=accent,
|
|
3283
|
+
)
|
|
3284
|
+
else:
|
|
3285
|
+
sections = spec.get("sections") or []
|
|
3286
|
+
_open_panel(
|
|
3287
|
+
title,
|
|
3288
|
+
lambda width, _s=sections, _h=hint: _render_kv_panel_rows(
|
|
3289
|
+
_s, width, _h
|
|
3290
|
+
),
|
|
3291
|
+
confirm=confirm,
|
|
3292
|
+
on_confirm=on_confirm,
|
|
3293
|
+
accent=accent,
|
|
3294
|
+
)
|
|
3295
|
+
return
|
|
3296
|
+
# spec is None → not a panel for this argument; fall through below.
|
|
3297
|
+
|
|
3298
|
+
# Picker commands (e.g. bare /mode) open the selectable popup. Only the
|
|
3299
|
+
# no-arg form opens the picker; "/mode fast" falls through to the command
|
|
3300
|
+
# runner so an explicit choice still applies inline.
|
|
3301
|
+
if picker_providers:
|
|
3302
|
+
parts = stripped.split()
|
|
3303
|
+
name = parts[0].lower()
|
|
3304
|
+
if len(parts) == 1 and name in picker_providers:
|
|
3305
|
+
try:
|
|
3306
|
+
spec = picker_providers[name]()
|
|
3307
|
+
except Exception as exc: # noqa: BLE001 - never crash the UI on a picker
|
|
3308
|
+
buff.reset()
|
|
3309
|
+
transcript.append("error", f"{name} failed: {exc}")
|
|
3310
|
+
_safe_invalidate()
|
|
3311
|
+
return
|
|
3312
|
+
if spec and spec.get("rows"):
|
|
3313
|
+
buff.reset()
|
|
3314
|
+
_open_picker(spec)
|
|
3315
|
+
return
|
|
3316
|
+
# Nothing to pick (e.g. no subagents registered) → fall through to
|
|
3317
|
+
# the command runner so it can explain/guide instead of silently
|
|
3318
|
+
# swallowing the command.
|
|
3319
|
+
|
|
3320
|
+
# Real path with slash-command support: route every submission through
|
|
3321
|
+
# the chat command handler (it returns "run" for plain messages).
|
|
3322
|
+
if session is not None and command_runner is not None:
|
|
3323
|
+
_dispatch_command(text)
|
|
3324
|
+
return
|
|
3325
|
+
|
|
3326
|
+
# No command runner (Phase 2 fake session / tests): exit words + run.
|
|
3327
|
+
if stripped.lower() in _EXIT_WORDS:
|
|
3328
|
+
get_app().exit(result=stripped)
|
|
3329
|
+
return
|
|
3330
|
+
if session is not None:
|
|
3331
|
+
buff.reset()
|
|
3332
|
+
transcript.append_user(text)
|
|
3333
|
+
_begin_run(text, {})
|
|
3334
|
+
return
|
|
3335
|
+
|
|
3336
|
+
# Shell-only path: keep configuration/help available while model calls are
|
|
3337
|
+
# blocked. Phase-1 tests still get the historical preview reply when no
|
|
3338
|
+
# explicit blocker was supplied.
|
|
3339
|
+
transcript.append_user(text)
|
|
3340
|
+
if unavailable_message:
|
|
3341
|
+
transcript.append("warn", unavailable_message)
|
|
3342
|
+
else:
|
|
3343
|
+
transcript.append("system", _PREVIEW_REPLY)
|
|
3344
|
+
buff.reset()
|
|
3345
|
+
_safe_invalidate()
|
|
3346
|
+
|
|
3347
|
+
# ---- footer ----
|
|
3348
|
+
def _footer_text() -> FormattedText:
|
|
3349
|
+
try:
|
|
3350
|
+
width = get_app().output.get_size().columns
|
|
3351
|
+
except Exception:
|
|
3352
|
+
width = 80
|
|
3353
|
+
return footer_fragments(state, width=width)
|
|
3354
|
+
|
|
3355
|
+
footer_window = Window(FormattedTextControl(_footer_text, focusable=False), height=2)
|
|
3356
|
+
|
|
3357
|
+
body = HSplit(
|
|
3358
|
+
[
|
|
3359
|
+
ConditionalContainer(welcome_window, filter=welcome_visible),
|
|
3360
|
+
ConditionalContainer(Window(), filter=welcome_completion_blank),
|
|
3361
|
+
ConditionalContainer(transcript_window, filter=has_messages),
|
|
3362
|
+
]
|
|
3363
|
+
)
|
|
3364
|
+
|
|
3365
|
+
# Welcome state: blank rows above/below the input line so the text sits
|
|
3366
|
+
# vertically centered in the box. Conversation state: blanks collapse.
|
|
3367
|
+
input_inner = HSplit(
|
|
3368
|
+
[
|
|
3369
|
+
ConditionalContainer(Window(height=1), filter=no_messages),
|
|
3370
|
+
input_area,
|
|
3371
|
+
ConditionalContainer(Window(height=1), filter=no_messages),
|
|
3372
|
+
]
|
|
3373
|
+
)
|
|
3374
|
+
input_frame = Frame(input_inner)
|
|
3375
|
+
|
|
3376
|
+
def _side_width() -> Any:
|
|
3377
|
+
if _conversation_started():
|
|
3378
|
+
return D.exact(0)
|
|
3379
|
+
try:
|
|
3380
|
+
cols = get_app().output.get_size().columns
|
|
3381
|
+
except Exception:
|
|
3382
|
+
cols = 80
|
|
3383
|
+
box = min(64, max(28, cols - 10))
|
|
3384
|
+
return D.exact(max(0, (cols - box) // 2))
|
|
3385
|
+
|
|
3386
|
+
def _box_height() -> int:
|
|
3387
|
+
return 5 if not _conversation_started() else 3
|
|
3388
|
+
|
|
3389
|
+
input_row = VSplit(
|
|
3390
|
+
[
|
|
3391
|
+
Window(width=_side_width, height=_box_height),
|
|
3392
|
+
input_frame,
|
|
3393
|
+
Window(width=_side_width, height=_box_height),
|
|
3394
|
+
]
|
|
3395
|
+
)
|
|
3396
|
+
subagent_panel_container = _subagent_panel_container(subagent_panel)
|
|
3397
|
+
|
|
3398
|
+
root = HSplit(
|
|
3399
|
+
[
|
|
3400
|
+
body,
|
|
3401
|
+
# Working/status line only appears once a conversation is underway, so
|
|
3402
|
+
# the welcome screen keeps its Phase 1 spacing exactly.
|
|
3403
|
+
ConditionalContainer(status_window, filter=has_messages),
|
|
3404
|
+
subagent_panel_container,
|
|
3405
|
+
input_row,
|
|
3406
|
+
Window(height=1),
|
|
3407
|
+
footer_window,
|
|
3408
|
+
]
|
|
3409
|
+
)
|
|
3410
|
+
|
|
3411
|
+
# ---- /help popup (centered Float over the root; opaque command panel) ----
|
|
3412
|
+
_help_open = Condition(lambda: help_box["on"])
|
|
3413
|
+
|
|
3414
|
+
def _resolve_help_sections() -> list[HelpSection]:
|
|
3415
|
+
if help_sections is not None:
|
|
3416
|
+
sections = list(help_sections)
|
|
3417
|
+
else:
|
|
3418
|
+
try:
|
|
3419
|
+
from ..commands.welcome import _chat_command_sections
|
|
3420
|
+
|
|
3421
|
+
sections = list(_chat_command_sections(ui_mode="chat") or [])
|
|
3422
|
+
except Exception:
|
|
3423
|
+
sections = []
|
|
3424
|
+
if not sections:
|
|
3425
|
+
sections = _fallback_help_sections()
|
|
3426
|
+
return sections
|
|
3427
|
+
|
|
3428
|
+
def _help_fragments() -> FormattedText:
|
|
3429
|
+
# Derived from the help float's own width, less its frame AND its
|
|
3430
|
+
# scrollbar margin — the margin is a content column, so a builder that
|
|
3431
|
+
# forgets it renders every row one column too wide (see "float geometry").
|
|
3432
|
+
width = _help_content_width_for(_current_width())
|
|
3433
|
+
builder = help_box.get("builder")
|
|
3434
|
+
if builder is None:
|
|
3435
|
+
rows = _help_rows_for_sections(_resolve_help_sections(), max(20, width))
|
|
3436
|
+
else:
|
|
3437
|
+
rows = builder(max(20, width))
|
|
3438
|
+
help_rows["n"] = len(rows)
|
|
3439
|
+
fragments: list[tuple[str, str]] = []
|
|
3440
|
+
for index, row in enumerate(rows):
|
|
3441
|
+
if index:
|
|
3442
|
+
fragments.append(("", "\n"))
|
|
3443
|
+
fragments.extend(row)
|
|
3444
|
+
return FormattedText(fragments)
|
|
3445
|
+
|
|
3446
|
+
# Scroll math: the cursor is pinned to the TOP of the viewport via the huge
|
|
3447
|
+
# bottom scroll-offset, so vertical_scroll tracks the offset 1:1. The max scroll
|
|
3448
|
+
# (follow-top) is taken STRAIGHT from render_info — `content_height -
|
|
3449
|
+
# window_height` is the exact value the ScrollbarMargin treats as fully
|
|
3450
|
+
# scrolled, so the clamp and the scrollbar agree and the thumb reaches the end.
|
|
3451
|
+
def _help_win_height() -> int:
|
|
3452
|
+
info = help_window.render_info
|
|
3453
|
+
return info.window_height if info is not None else 0
|
|
3454
|
+
|
|
3455
|
+
def _help_follow_top() -> int:
|
|
3456
|
+
info = help_window.render_info
|
|
3457
|
+
if info is None:
|
|
3458
|
+
return max(0, help_rows["n"] - 1)
|
|
3459
|
+
return max(0, info.content_height - info.window_height)
|
|
3460
|
+
|
|
3461
|
+
def _help_cursor_row() -> int:
|
|
3462
|
+
return max(0, min(int(help_box["offset"]), _help_follow_top()))
|
|
3463
|
+
|
|
3464
|
+
def _help_panel_width() -> int:
|
|
3465
|
+
return _help_panel_width_for(_current_width())
|
|
3466
|
+
|
|
3467
|
+
def _help_panel_height() -> int:
|
|
3468
|
+
try:
|
|
3469
|
+
rows = get_app().output.get_size().rows
|
|
3470
|
+
except Exception:
|
|
3471
|
+
rows = 24
|
|
3472
|
+
# Outer frame height; leave a clear top/bottom margin and never overflow
|
|
3473
|
+
# the screen, so render_info is exact and scrolling can reach the bottom.
|
|
3474
|
+
return max(6, rows - 6)
|
|
3475
|
+
|
|
3476
|
+
help_window = Window(
|
|
3477
|
+
_ScrollableControl(
|
|
3478
|
+
_help_fragments,
|
|
3479
|
+
focusable=True,
|
|
3480
|
+
show_cursor=False,
|
|
3481
|
+
get_cursor_position=lambda: Point(x=0, y=_help_cursor_row()),
|
|
3482
|
+
on_scroll=lambda direction: _help_scroll(direction * wheel_step_rows),
|
|
3483
|
+
),
|
|
3484
|
+
# MUST be True: the cursor-pin scroll trick only works on prompt_toolkit's
|
|
3485
|
+
# line-wrapping scroll path (_scroll_when_linewrapping). With wrap_lines
|
|
3486
|
+
# False the offset never moved vertical_scroll, so the scrollbar thumb
|
|
3487
|
+
# could not reach the bottom. Rows are pre-padded to the content width, so
|
|
3488
|
+
# nothing actually wraps.
|
|
3489
|
+
wrap_lines=True,
|
|
3490
|
+
style="class:tui.help",
|
|
3491
|
+
scroll_offsets=ScrollOffsets(bottom=10**6),
|
|
3492
|
+
right_margins=[ScrollbarMargin(display_arrows=False)],
|
|
3493
|
+
)
|
|
3494
|
+
help_frame = Frame(help_window, title="Commands", style="class:tui.help.frame")
|
|
3495
|
+
|
|
3496
|
+
def _panel_frame_accent_style() -> str:
|
|
3497
|
+
# A container style callable: while a Forge panel is open it adds the
|
|
3498
|
+
# ``tui.forgeframe`` ancestor class, which (declared after the green
|
|
3499
|
+
# ``tui.help.frame frame.border`` rule) repaints the frame border violet.
|
|
3500
|
+
# Empty string for every other panel keeps the default green chrome.
|
|
3501
|
+
return "class:tui.forgeframe" if help_box.get("accent") == "forge" else ""
|
|
3502
|
+
|
|
3503
|
+
# Wrap the shared frame so the accent class can be toggled per open panel
|
|
3504
|
+
# without rebuilding the Frame (whose style is baked in at construction).
|
|
3505
|
+
help_frame_wrapper = HSplit([help_frame], style=_panel_frame_accent_style)
|
|
3506
|
+
help_float = Float(
|
|
3507
|
+
content=ConditionalContainer(help_frame_wrapper, filter=_help_open),
|
|
3508
|
+
width=_help_panel_width,
|
|
3509
|
+
height=_help_panel_height,
|
|
3510
|
+
)
|
|
3511
|
+
|
|
3512
|
+
def _open_panel(
|
|
3513
|
+
title: str,
|
|
3514
|
+
builder: Callable[[int], list[list[tuple[str, str]]]],
|
|
3515
|
+
*,
|
|
3516
|
+
confirm: str | None = None,
|
|
3517
|
+
on_confirm: Callable[[], list[tuple[str, str]] | None] | None = None,
|
|
3518
|
+
accent: str | None = None,
|
|
3519
|
+
) -> None:
|
|
3520
|
+
# Open the centered popup with an arbitrary title + row-builder. /help and
|
|
3521
|
+
# every panel command (/status, …) route through here so there is one
|
|
3522
|
+
# overlay, one set of scroll/close keys, one Float. ``confirm`` (when set)
|
|
3523
|
+
# makes Enter run that command instead of merely closing (confirm dialog);
|
|
3524
|
+
# ``on_confirm`` is the callback form — Enter runs it (and shows any returned
|
|
3525
|
+
# transcript messages) instead of dispatching a command. Esc/q always
|
|
3526
|
+
# cancel (neither fires).
|
|
3527
|
+
help_box["on"] = True
|
|
3528
|
+
help_box["offset"] = 0
|
|
3529
|
+
help_box["title"] = title
|
|
3530
|
+
help_box["builder"] = builder
|
|
3531
|
+
help_box["confirm"] = confirm
|
|
3532
|
+
help_box["on_confirm"] = on_confirm
|
|
3533
|
+
help_box["accent"] = accent
|
|
3534
|
+
try:
|
|
3535
|
+
help_frame.title = title
|
|
3536
|
+
except Exception:
|
|
3537
|
+
pass
|
|
3538
|
+
try:
|
|
3539
|
+
get_app().layout.focus(help_window)
|
|
3540
|
+
except Exception:
|
|
3541
|
+
pass
|
|
3542
|
+
_safe_invalidate()
|
|
3543
|
+
|
|
3544
|
+
def _open_help() -> None:
|
|
3545
|
+
_open_panel(
|
|
3546
|
+
"Commands",
|
|
3547
|
+
lambda width: _help_rows_for_sections(_resolve_help_sections(), width),
|
|
3548
|
+
)
|
|
3549
|
+
|
|
3550
|
+
def _close_help() -> None:
|
|
3551
|
+
if not help_box["on"]:
|
|
3552
|
+
return
|
|
3553
|
+
help_box["on"] = False
|
|
3554
|
+
help_box["confirm"] = None
|
|
3555
|
+
help_box["on_confirm"] = None
|
|
3556
|
+
help_box["accent"] = None
|
|
3557
|
+
try:
|
|
3558
|
+
get_app().layout.focus(input_area)
|
|
3559
|
+
except Exception:
|
|
3560
|
+
pass
|
|
3561
|
+
_safe_invalidate()
|
|
3562
|
+
|
|
3563
|
+
def _help_page_rows() -> int:
|
|
3564
|
+
return max(1, _help_win_height() - 1)
|
|
3565
|
+
|
|
3566
|
+
def _help_scroll(delta: int) -> None:
|
|
3567
|
+
new_offset, _follow = _scroll_target(int(help_box["offset"]), _help_follow_top(), delta)
|
|
3568
|
+
help_box["offset"] = new_offset
|
|
3569
|
+
_safe_invalidate()
|
|
3570
|
+
|
|
3571
|
+
# ---- picker popup (centered Float; selectable option list, e.g. /mode) ----
|
|
3572
|
+
_picker_open = Condition(lambda: picker_box["on"])
|
|
3573
|
+
|
|
3574
|
+
def _picker_fragments() -> FormattedText:
|
|
3575
|
+
# render_info is only assigned AFTER create_content has run, so the first
|
|
3576
|
+
# frame a popup is shown has none and must fall back to arithmetic — from
|
|
3577
|
+
# the picker's OWN float, never the help panel's (see "float geometry").
|
|
3578
|
+
width = _picker_content_width()
|
|
3579
|
+
rows = _render_picker_rows(
|
|
3580
|
+
picker_box["rows"], int(picker_box["index"]), max(20, width), picker_box["hint"]
|
|
3581
|
+
)
|
|
3582
|
+
fragments: list[tuple[str, str]] = []
|
|
3583
|
+
for index, row in enumerate(rows):
|
|
3584
|
+
if index:
|
|
3585
|
+
fragments.append(("", "\n"))
|
|
3586
|
+
fragments.extend(row)
|
|
3587
|
+
return FormattedText(fragments)
|
|
3588
|
+
|
|
3589
|
+
def _picker_width() -> int:
|
|
3590
|
+
return _picker_width_for(_current_width())
|
|
3591
|
+
|
|
3592
|
+
def _picker_content_width() -> int:
|
|
3593
|
+
# The single source of truth for every picker row build, so the fragments,
|
|
3594
|
+
# the height and the cursor row can never disagree about the width.
|
|
3595
|
+
return _picker_content_width_for(_current_width())
|
|
3596
|
+
|
|
3597
|
+
def _picker_height() -> int:
|
|
3598
|
+
try:
|
|
3599
|
+
rows = get_app().output.get_size().rows
|
|
3600
|
+
except Exception:
|
|
3601
|
+
rows = 24
|
|
3602
|
+
# Descriptions may wrap to a few lines, so size from the ACTUAL rendered
|
|
3603
|
+
# line count (independent of which row is selected) at the panel's inner
|
|
3604
|
+
# width; plus the 2-row frame border, capped to the screen.
|
|
3605
|
+
content = len(
|
|
3606
|
+
_render_picker_rows(
|
|
3607
|
+
picker_box["rows"],
|
|
3608
|
+
int(picker_box["index"]),
|
|
3609
|
+
_picker_content_width(),
|
|
3610
|
+
picker_box["hint"],
|
|
3611
|
+
)
|
|
3612
|
+
)
|
|
3613
|
+
return max(5, min(content + _FRAME_COLS, rows - 4))
|
|
3614
|
+
|
|
3615
|
+
def _picker_cursor_position() -> Point:
|
|
3616
|
+
# Report the cursor at the selected entry's first line so the Window scrolls
|
|
3617
|
+
# to keep the highlighted option visible — essential when a long list (e.g.
|
|
3618
|
+
# /resume with many sessions) is taller than the popup. The selected entry's
|
|
3619
|
+
# first line is the only one carrying the accent caret style, so find it in
|
|
3620
|
+
# the freshly rendered rows (at the panel's real content width).
|
|
3621
|
+
rows = _render_picker_rows(
|
|
3622
|
+
picker_box["rows"],
|
|
3623
|
+
int(picker_box["index"]),
|
|
3624
|
+
_picker_content_width(),
|
|
3625
|
+
picker_box["hint"],
|
|
3626
|
+
)
|
|
3627
|
+
for line_no, line in enumerate(rows):
|
|
3628
|
+
if any(style == "class:tui.picker.selcaret" for style, _text in line):
|
|
3629
|
+
return Point(0, line_no)
|
|
3630
|
+
return Point(0, 0)
|
|
3631
|
+
|
|
3632
|
+
picker_window = Window(
|
|
3633
|
+
FormattedTextControl(
|
|
3634
|
+
_picker_fragments,
|
|
3635
|
+
focusable=True,
|
|
3636
|
+
show_cursor=False,
|
|
3637
|
+
get_cursor_position=_picker_cursor_position,
|
|
3638
|
+
),
|
|
3639
|
+
wrap_lines=True,
|
|
3640
|
+
style="class:tui.picker",
|
|
3641
|
+
)
|
|
3642
|
+
picker_frame = Frame(picker_window, title="Select", style="class:tui.help.frame")
|
|
3643
|
+
picker_float = Float(
|
|
3644
|
+
content=ConditionalContainer(picker_frame, filter=_picker_open),
|
|
3645
|
+
width=_picker_width,
|
|
3646
|
+
height=_picker_height,
|
|
3647
|
+
)
|
|
3648
|
+
|
|
3649
|
+
def _close_picker() -> None:
|
|
3650
|
+
if not picker_box["on"]:
|
|
3651
|
+
return
|
|
3652
|
+
picker_box["on"] = False
|
|
3653
|
+
try:
|
|
3654
|
+
get_app().layout.focus(input_area)
|
|
3655
|
+
except Exception:
|
|
3656
|
+
pass
|
|
3657
|
+
_safe_invalidate()
|
|
3658
|
+
|
|
3659
|
+
def _open_picker(spec: dict[str, Any]) -> None:
|
|
3660
|
+
rows = list(spec.get("rows") or [])
|
|
3661
|
+
picker_box["on"] = True
|
|
3662
|
+
picker_box["rows"] = rows
|
|
3663
|
+
picker_box["title"] = spec.get("title") or "Select"
|
|
3664
|
+
picker_box["hint"] = spec.get("hint") or _PICKER_HINT
|
|
3665
|
+
picker_box["on_select"] = spec.get("on_select")
|
|
3666
|
+
# Pre-select the row flagged as current (else the first option).
|
|
3667
|
+
picker_box["index"] = next((i for i, r in enumerate(rows) if r.get("current")), 0)
|
|
3668
|
+
try:
|
|
3669
|
+
picker_frame.title = picker_box["title"]
|
|
3670
|
+
except Exception:
|
|
3671
|
+
pass
|
|
3672
|
+
try:
|
|
3673
|
+
get_app().layout.focus(picker_window)
|
|
3674
|
+
except Exception:
|
|
3675
|
+
pass
|
|
3676
|
+
_safe_invalidate()
|
|
3677
|
+
|
|
3678
|
+
def _picker_move(delta: int) -> None:
|
|
3679
|
+
count = len(picker_box["rows"])
|
|
3680
|
+
if count:
|
|
3681
|
+
picker_box["index"] = max(0, min(int(picker_box["index"]) + delta, count - 1))
|
|
3682
|
+
_safe_invalidate()
|
|
3683
|
+
|
|
3684
|
+
def _picker_choose(idx: int) -> None:
|
|
3685
|
+
rows = picker_box["rows"]
|
|
3686
|
+
if not rows or idx < 0 or idx >= len(rows):
|
|
3687
|
+
return
|
|
3688
|
+
value = rows[idx].get("value")
|
|
3689
|
+
on_select = picker_box["on_select"]
|
|
3690
|
+
_close_picker()
|
|
3691
|
+
if on_select is None or value is None:
|
|
3692
|
+
return
|
|
3693
|
+
try:
|
|
3694
|
+
result = on_select(value)
|
|
3695
|
+
except Exception as exc: # noqa: BLE001 - never crash the UI on a selection
|
|
3696
|
+
transcript.append("error", f"selection failed: {exc}")
|
|
3697
|
+
result = None
|
|
3698
|
+
# on_select returns either a list of (role, text) messages to echo, or a
|
|
3699
|
+
# dict {"messages"?: [...], "prefill"?: str, "submit"?: str, "exit"?: Any}.
|
|
3700
|
+
# · ``prefill`` drops text into the input box (cursor at end, focused)
|
|
3701
|
+
# so a picked option can be finished by typing.
|
|
3702
|
+
# · ``submit`` runs text straight through the submit pipeline as if typed —
|
|
3703
|
+
# e.g. a forge /plan option immediately opens its panel (no extra Enter).
|
|
3704
|
+
messages = result
|
|
3705
|
+
prefill: str | None = None
|
|
3706
|
+
submit: str | None = None
|
|
3707
|
+
exit_result: Any | None = None
|
|
3708
|
+
next_picker: dict[str, Any] | None = None
|
|
3709
|
+
if isinstance(result, dict):
|
|
3710
|
+
messages = result.get("messages")
|
|
3711
|
+
prefill = result.get("prefill")
|
|
3712
|
+
submit = result.get("submit")
|
|
3713
|
+
exit_result = result.get("exit")
|
|
3714
|
+
next_picker = result.get("picker")
|
|
3715
|
+
for role, text in messages or []:
|
|
3716
|
+
transcript.append(role, text)
|
|
3717
|
+
if next_picker is not None:
|
|
3718
|
+
# Re-open a (freshly rebuilt) picker — e.g. the launch gate cycles a
|
|
3719
|
+
# swarm knob and re-presents the menu with the updated value.
|
|
3720
|
+
_open_picker(next_picker)
|
|
3721
|
+
_safe_invalidate()
|
|
3722
|
+
return
|
|
3723
|
+
if exit_result is not None:
|
|
3724
|
+
get_app().exit(result=exit_result)
|
|
3725
|
+
return
|
|
3726
|
+
if submit is not None:
|
|
3727
|
+
try:
|
|
3728
|
+
input_area.text = str(submit)
|
|
3729
|
+
input_area.buffer.cursor_position = len(input_area.text)
|
|
3730
|
+
get_app().layout.focus(input_area)
|
|
3731
|
+
_submit()
|
|
3732
|
+
except Exception: # noqa: BLE001 - submit is best-effort
|
|
3733
|
+
pass
|
|
3734
|
+
scroll["follow"] = True
|
|
3735
|
+
_safe_invalidate()
|
|
3736
|
+
return
|
|
3737
|
+
if prefill is not None:
|
|
3738
|
+
try:
|
|
3739
|
+
prefill_text = str(prefill)
|
|
3740
|
+
input_area.text = prefill_text
|
|
3741
|
+
input_area.buffer.cursor_position = len(prefill_text)
|
|
3742
|
+
get_app().layout.focus(input_area)
|
|
3743
|
+
except Exception: # noqa: BLE001 - prefill is best-effort
|
|
3744
|
+
pass
|
|
3745
|
+
scroll["follow"] = True
|
|
3746
|
+
_safe_invalidate()
|
|
3747
|
+
|
|
3748
|
+
def _defer_plan_mode_approval(
|
|
3749
|
+
*,
|
|
3750
|
+
user_message: str,
|
|
3751
|
+
draft: str,
|
|
3752
|
+
approved_instruction: str,
|
|
3753
|
+
) -> None:
|
|
3754
|
+
task = " ".join(str(user_message or "").split())
|
|
3755
|
+
_ = draft
|
|
3756
|
+
preview = task if len(task) <= 72 else task[:69].rstrip() + "..."
|
|
3757
|
+
rows: list[PickerRow] = [
|
|
3758
|
+
{
|
|
3759
|
+
"value": "approve",
|
|
3760
|
+
"label": "Approve and execute",
|
|
3761
|
+
"description": "Run the task immediately using this approved draft.",
|
|
3762
|
+
"current": True,
|
|
3763
|
+
},
|
|
3764
|
+
{
|
|
3765
|
+
"value": "propose",
|
|
3766
|
+
"label": "Propose changes",
|
|
3767
|
+
"description": "Edit the task text and draft again with your requested changes.",
|
|
3768
|
+
},
|
|
3769
|
+
{
|
|
3770
|
+
"value": "discard",
|
|
3771
|
+
"label": "Discard this plan",
|
|
3772
|
+
"description": "Cancel this draft and return to chat.",
|
|
3773
|
+
},
|
|
3774
|
+
]
|
|
3775
|
+
|
|
3776
|
+
def _on_select(value: Any) -> Any:
|
|
3777
|
+
selected = str(value or "").strip().lower()
|
|
3778
|
+
if selected == "approve":
|
|
3779
|
+
instruction = str(approved_instruction or "")
|
|
3780
|
+
if not instruction.strip():
|
|
3781
|
+
return [("error", "Approved plan was empty; nothing to execute.")]
|
|
3782
|
+
label = (
|
|
3783
|
+
f"Executing approved plan: {preview}" if preview else "Executing approved plan."
|
|
3784
|
+
)
|
|
3785
|
+
transcript.append("system", label)
|
|
3786
|
+
_begin_run(instruction, {})
|
|
3787
|
+
return None
|
|
3788
|
+
if selected == "propose":
|
|
3789
|
+
prefill = f"/plan {task} " if task else "/plan "
|
|
3790
|
+
return {
|
|
3791
|
+
"messages": [
|
|
3792
|
+
(
|
|
3793
|
+
"system",
|
|
3794
|
+
"Edit the /plan task with the requested changes, "
|
|
3795
|
+
"then press Enter to draft again.",
|
|
3796
|
+
)
|
|
3797
|
+
],
|
|
3798
|
+
"prefill": prefill,
|
|
3799
|
+
}
|
|
3800
|
+
return [("system", "Discarded plan. What do you want to build next?")]
|
|
3801
|
+
|
|
3802
|
+
_open_picker(
|
|
3803
|
+
{
|
|
3804
|
+
"title": "Plan approval",
|
|
3805
|
+
"hint": (
|
|
3806
|
+
"Up/Down move / 1 approve / 2 revise / 3 discard / Enter select / Esc cancel"
|
|
3807
|
+
),
|
|
3808
|
+
"rows": rows,
|
|
3809
|
+
"on_select": _on_select,
|
|
3810
|
+
}
|
|
3811
|
+
)
|
|
3812
|
+
|
|
3813
|
+
if surface is not None:
|
|
3814
|
+
surface.defer_plan_mode_approval = _defer_plan_mode_approval
|
|
3815
|
+
|
|
3816
|
+
# ---- in-TUI editor (centered Float; e.g. /plan edit on plan.json) ----
|
|
3817
|
+
_editor_open = Condition(lambda: editor_box["on"])
|
|
3818
|
+
|
|
3819
|
+
editor_area = TextArea(
|
|
3820
|
+
multiline=True,
|
|
3821
|
+
wrap_lines=False,
|
|
3822
|
+
scrollbar=True,
|
|
3823
|
+
line_numbers=True,
|
|
3824
|
+
focusable=True,
|
|
3825
|
+
style="class:tui.editor",
|
|
3826
|
+
)
|
|
3827
|
+
|
|
3828
|
+
def _editor_status_bar() -> FormattedText:
|
|
3829
|
+
status = str(editor_box.get("status") or "")
|
|
3830
|
+
hint = "Ctrl+S save · Esc cancel"
|
|
3831
|
+
if status:
|
|
3832
|
+
return FormattedText(
|
|
3833
|
+
[("class:tui.editor.err", f" {status} "), ("class:tui.editor.hint", hint)]
|
|
3834
|
+
)
|
|
3835
|
+
return FormattedText([("class:tui.editor.hint", f" {hint}")])
|
|
3836
|
+
|
|
3837
|
+
def _editor_width() -> int:
|
|
3838
|
+
try:
|
|
3839
|
+
cols = get_app().output.get_size().columns
|
|
3840
|
+
except Exception:
|
|
3841
|
+
cols = 80
|
|
3842
|
+
return max(40, min(cols - 6, 100))
|
|
3843
|
+
|
|
3844
|
+
def _editor_height() -> int:
|
|
3845
|
+
try:
|
|
3846
|
+
rows = get_app().output.get_size().rows
|
|
3847
|
+
except Exception:
|
|
3848
|
+
rows = 24
|
|
3849
|
+
return max(8, rows - 4)
|
|
3850
|
+
|
|
3851
|
+
editor_body = HSplit(
|
|
3852
|
+
[
|
|
3853
|
+
editor_area,
|
|
3854
|
+
Window(
|
|
3855
|
+
FormattedTextControl(_editor_status_bar, focusable=False),
|
|
3856
|
+
height=1,
|
|
3857
|
+
style="class:tui.editor.statusbar",
|
|
3858
|
+
),
|
|
3859
|
+
]
|
|
3860
|
+
)
|
|
3861
|
+
editor_frame = Frame(editor_body, title="Edit", style="class:tui.help.frame")
|
|
3862
|
+
editor_float = Float(
|
|
3863
|
+
content=ConditionalContainer(editor_frame, filter=_editor_open),
|
|
3864
|
+
width=_editor_width,
|
|
3865
|
+
height=_editor_height,
|
|
3866
|
+
)
|
|
3867
|
+
|
|
3868
|
+
def _open_editor(spec: dict[str, Any]) -> None:
|
|
3869
|
+
editor_box["on"] = True
|
|
3870
|
+
editor_box["title"] = spec.get("title") or "Edit"
|
|
3871
|
+
editor_box["on_save"] = spec.get("on_save")
|
|
3872
|
+
editor_box["status"] = ""
|
|
3873
|
+
editor_area.text = str(spec.get("text") or "")
|
|
3874
|
+
editor_area.buffer.cursor_position = 0
|
|
3875
|
+
try:
|
|
3876
|
+
editor_frame.title = editor_box["title"]
|
|
3877
|
+
except Exception:
|
|
3878
|
+
pass
|
|
3879
|
+
try:
|
|
3880
|
+
get_app().layout.focus(editor_area)
|
|
3881
|
+
except Exception:
|
|
3882
|
+
pass
|
|
3883
|
+
_safe_invalidate()
|
|
3884
|
+
|
|
3885
|
+
def _close_editor() -> None:
|
|
3886
|
+
if not editor_box["on"]:
|
|
3887
|
+
return
|
|
3888
|
+
editor_box["on"] = False
|
|
3889
|
+
editor_box["on_save"] = None
|
|
3890
|
+
try:
|
|
3891
|
+
get_app().layout.focus(input_area)
|
|
3892
|
+
except Exception:
|
|
3893
|
+
pass
|
|
3894
|
+
_safe_invalidate()
|
|
3895
|
+
|
|
3896
|
+
def _editor_save() -> None:
|
|
3897
|
+
on_save = editor_box.get("on_save")
|
|
3898
|
+
if not callable(on_save):
|
|
3899
|
+
_close_editor()
|
|
3900
|
+
return
|
|
3901
|
+
try:
|
|
3902
|
+
ok, message = on_save(editor_area.text)
|
|
3903
|
+
except Exception as exc: # noqa: BLE001 - never crash the UI on a save
|
|
3904
|
+
ok, message = False, f"Save failed: {exc}"
|
|
3905
|
+
if ok:
|
|
3906
|
+
_close_editor()
|
|
3907
|
+
if message:
|
|
3908
|
+
transcript.append("system", str(message))
|
|
3909
|
+
scroll["follow"] = True
|
|
3910
|
+
else:
|
|
3911
|
+
# Keep editing so the user can fix the error (shown in the status bar).
|
|
3912
|
+
editor_box["status"] = str(message or "Invalid input.")
|
|
3913
|
+
_safe_invalidate()
|
|
3914
|
+
|
|
3915
|
+
# ---- approval modal (centered Float; shown while an approval is pending) ----
|
|
3916
|
+
def _approval_fragments() -> FormattedText:
|
|
3917
|
+
request = approval_box.get("request")
|
|
3918
|
+
if request is None:
|
|
3919
|
+
return FormattedText([])
|
|
3920
|
+
rows = _render_approval_rows(
|
|
3921
|
+
request,
|
|
3922
|
+
_approval_content_width_for(_current_width()),
|
|
3923
|
+
max_body_lines=_approval_body_budget(_current_rows()),
|
|
3924
|
+
)
|
|
3925
|
+
fragments: list[tuple[str, str]] = []
|
|
3926
|
+
for index, row in enumerate(rows):
|
|
3927
|
+
if index:
|
|
3928
|
+
fragments.append(("", "\n"))
|
|
3929
|
+
fragments.extend(row)
|
|
3930
|
+
return FormattedText(fragments)
|
|
3931
|
+
|
|
3932
|
+
def _approval_width() -> int:
|
|
3933
|
+
return _approval_width_for(_current_width())
|
|
3934
|
+
|
|
3935
|
+
def _approval_height() -> int:
|
|
3936
|
+
request = approval_box.get("request")
|
|
3937
|
+
if request is None:
|
|
3938
|
+
return 6
|
|
3939
|
+
rows = _current_rows()
|
|
3940
|
+
content = len(
|
|
3941
|
+
_render_approval_rows(
|
|
3942
|
+
request,
|
|
3943
|
+
_approval_content_width_for(_current_width()),
|
|
3944
|
+
max_body_lines=_approval_body_budget(rows),
|
|
3945
|
+
)
|
|
3946
|
+
)
|
|
3947
|
+
return max(6, min(content + _FRAME_COLS, rows - 4))
|
|
3948
|
+
|
|
3949
|
+
approval_window = Window(
|
|
3950
|
+
FormattedTextControl(_approval_fragments, focusable=False, show_cursor=False),
|
|
3951
|
+
wrap_lines=True,
|
|
3952
|
+
style="class:tui.approve",
|
|
3953
|
+
)
|
|
3954
|
+
# Static amber border (Frame.style must be a string); destructive actions are
|
|
3955
|
+
# signalled by the red headline + red [n] key inside the body instead.
|
|
3956
|
+
approval_frame = Frame(
|
|
3957
|
+
approval_window, title="Approval required", style="class:tui.approve.frame"
|
|
3958
|
+
)
|
|
3959
|
+
approval_float = Float(
|
|
3960
|
+
content=ConditionalContainer(approval_frame, filter=_approval_pending),
|
|
3961
|
+
width=_approval_width,
|
|
3962
|
+
height=_approval_height,
|
|
3963
|
+
)
|
|
3964
|
+
|
|
3965
|
+
# ---- /config overlay (full-screen modal driven by ConfigFlow) ----
|
|
3966
|
+
# Built only when the host wires a flow factory. It hosts the whole interactive
|
|
3967
|
+
# configuration menu inside this same Application (the chat transcript/session
|
|
3968
|
+
# survive), so bare `/config` no longer drops to a read-only panel.
|
|
3969
|
+
config_overlay = None
|
|
3970
|
+
if config_flow_factory is not None:
|
|
3971
|
+
from .config_overlay import ConfigOverlay
|
|
3972
|
+
|
|
3973
|
+
def _on_config_overlay_saved(count: int) -> None:
|
|
3974
|
+
if running["on"]:
|
|
3975
|
+
_stage_pending_config_reload(pending_operations)
|
|
3976
|
+
if count > 0:
|
|
3977
|
+
word = "change" if count == 1 else "changes"
|
|
3978
|
+
transcript.append(
|
|
3979
|
+
"system",
|
|
3980
|
+
f"Configuration saved ({count} {word}). The running session will "
|
|
3981
|
+
"reload when this turn finishes.",
|
|
3982
|
+
)
|
|
3983
|
+
else:
|
|
3984
|
+
transcript.append(
|
|
3985
|
+
"system",
|
|
3986
|
+
"Configuration saved (no changes). The running session will reload "
|
|
3987
|
+
"when this turn finishes.",
|
|
3988
|
+
)
|
|
3989
|
+
_safe_invalidate()
|
|
3990
|
+
return
|
|
3991
|
+
|
|
3992
|
+
reload_outcome = _reload_saved_config()
|
|
3993
|
+
if reload_outcome is ConfigReloadOutcome.APPLIED and count > 0:
|
|
3994
|
+
word = "change" if count == 1 else "changes"
|
|
3995
|
+
transcript.append(
|
|
3996
|
+
"system",
|
|
3997
|
+
f"Configuration saved ({count} {word}). New settings apply on the next turn.",
|
|
3998
|
+
)
|
|
3999
|
+
elif reload_outcome is ConfigReloadOutcome.APPLIED:
|
|
4000
|
+
transcript.append("system", "Configuration saved (no changes).")
|
|
4001
|
+
_safe_invalidate()
|
|
4002
|
+
|
|
4003
|
+
def _on_config_switch_workspace(path: str) -> None:
|
|
4004
|
+
# Exit the TUI with a sentinel result; the chat loop relaunches a fresh
|
|
4005
|
+
# session bound to this folder (the live workspace root can't change in
|
|
4006
|
+
# place). The transcript/session of the current project end here.
|
|
4007
|
+
try:
|
|
4008
|
+
get_app().exit(result=("switch_workspace", str(path)))
|
|
4009
|
+
except Exception: # noqa: BLE001 - never crash on teardown
|
|
4010
|
+
pass
|
|
4011
|
+
|
|
4012
|
+
config_overlay = ConfigOverlay(
|
|
4013
|
+
flow_factory=config_flow_factory,
|
|
4014
|
+
on_saved=_on_config_overlay_saved,
|
|
4015
|
+
on_error=lambda msg: (transcript.append("error", msg), _safe_invalidate()),
|
|
4016
|
+
on_switch_workspace=_on_config_switch_workspace,
|
|
4017
|
+
focus_chat=lambda: get_app().layout.focus(input_area),
|
|
4018
|
+
)
|
|
4019
|
+
_config_open = (
|
|
4020
|
+
config_overlay.open_condition if config_overlay is not None else Condition(lambda: False)
|
|
4021
|
+
)
|
|
4022
|
+
_config_floats = [config_overlay.float] if config_overlay is not None else []
|
|
4023
|
+
_small_modal_open = _help_open | _picker_open | _editor_open | _approval_pending
|
|
4024
|
+
_completion_allowed = ~_small_modal_open & ~_config_open
|
|
4025
|
+
|
|
4026
|
+
def _completion_float_width() -> int:
|
|
4027
|
+
try:
|
|
4028
|
+
cols = get_app().output.get_size().columns
|
|
4029
|
+
except Exception:
|
|
4030
|
+
cols = 80
|
|
4031
|
+
return _completion_menu_width(cols)
|
|
4032
|
+
|
|
4033
|
+
def _completion_float_height() -> int:
|
|
4034
|
+
try:
|
|
4035
|
+
rows = get_app().output.get_size().rows
|
|
4036
|
+
except Exception:
|
|
4037
|
+
rows = 24
|
|
4038
|
+
return _completion_menu_height(rows)
|
|
4039
|
+
|
|
4040
|
+
completion_float = Float(
|
|
4041
|
+
xcursor=True,
|
|
4042
|
+
bottom=_COMPLETION_MENU_BOTTOM,
|
|
4043
|
+
width=_completion_float_width,
|
|
4044
|
+
height=_completion_float_height,
|
|
4045
|
+
content=CompletionsMenu(
|
|
4046
|
+
max_height=_COMPLETION_MENU_MAX_HEIGHT,
|
|
4047
|
+
scroll_offset=1,
|
|
4048
|
+
extra_filter=_completion_allowed,
|
|
4049
|
+
),
|
|
4050
|
+
)
|
|
4051
|
+
modal_scrim = Float(
|
|
4052
|
+
content=ConditionalContainer(
|
|
4053
|
+
Window(char=" ", style="class:tui.modal.scrim"),
|
|
4054
|
+
filter=_small_modal_open,
|
|
4055
|
+
),
|
|
4056
|
+
left=0,
|
|
4057
|
+
right=0,
|
|
4058
|
+
top=0,
|
|
4059
|
+
bottom=0,
|
|
4060
|
+
)
|
|
4061
|
+
|
|
4062
|
+
root_container = FloatContainer(
|
|
4063
|
+
content=root,
|
|
4064
|
+
floats=[
|
|
4065
|
+
# Slash-command dropdown: horizontally follows the cursor, but is
|
|
4066
|
+
# vertically pinned above the input/footer chrome so it never eats the
|
|
4067
|
+
# input frame border.
|
|
4068
|
+
completion_float,
|
|
4069
|
+
# Opaque backing for the smaller centered modals. /config already owns
|
|
4070
|
+
# its full-screen opaque float; these panels need the same masking.
|
|
4071
|
+
modal_scrim,
|
|
4072
|
+
help_float,
|
|
4073
|
+
picker_float,
|
|
4074
|
+
editor_float,
|
|
4075
|
+
# The /config overlay covers the whole screen while open.
|
|
4076
|
+
*_config_floats,
|
|
4077
|
+
# Approval modal sits on top — it interrupts a running turn for a y/a/n.
|
|
4078
|
+
approval_float,
|
|
4079
|
+
],
|
|
4080
|
+
)
|
|
4081
|
+
|
|
4082
|
+
# ---- key bindings ----
|
|
4083
|
+
kb = KeyBindings()
|
|
4084
|
+
_input_focused = has_focus(input_area)
|
|
4085
|
+
# True while the slash-command dropdown is showing for the input buffer.
|
|
4086
|
+
_completing = Condition(lambda: input_area.buffer.complete_state is not None)
|
|
4087
|
+
_has_children = Condition(lambda: bool(subagent_panel["run_order"]))
|
|
4088
|
+
_subagent_panel_open = Condition(lambda: bool(subagent_panel["selected_run_id"]))
|
|
4089
|
+
# A turn (or its pending approval) is in flight — Esc / Ctrl+C interrupt it.
|
|
4090
|
+
_turn_active = Condition(lambda: running["on"] or approval_box.get("event") is not None)
|
|
4091
|
+
|
|
4092
|
+
def _navigate_subagent(delta: int) -> None:
|
|
4093
|
+
previous = str(subagent_panel["selected_run_id"] or "")
|
|
4094
|
+
_cycle_subagent_panel(subagent_panel, delta)
|
|
4095
|
+
_evict_subagent_panel_runs(
|
|
4096
|
+
subagent_panel,
|
|
4097
|
+
departed_run_id=previous,
|
|
4098
|
+
)
|
|
4099
|
+
|
|
4100
|
+
def _close_selected_subagent() -> None:
|
|
4101
|
+
previous = str(subagent_panel["selected_run_id"] or "")
|
|
4102
|
+
subagent_panel["selected_run_id"] = ""
|
|
4103
|
+
subagent_panel["last_poll"] = None
|
|
4104
|
+
_evict_subagent_panel_runs(
|
|
4105
|
+
subagent_panel,
|
|
4106
|
+
departed_run_id=previous,
|
|
4107
|
+
)
|
|
4108
|
+
|
|
4109
|
+
def _interrupt_or_exit(event: Any, *, copy_selection: bool) -> None:
|
|
4110
|
+
if config_overlay is not None and config_overlay.is_open():
|
|
4111
|
+
config_overlay.request_cancel()
|
|
4112
|
+
return
|
|
4113
|
+
if help_box["on"]:
|
|
4114
|
+
_close_help()
|
|
4115
|
+
return
|
|
4116
|
+
if picker_box["on"]:
|
|
4117
|
+
_close_picker()
|
|
4118
|
+
return
|
|
4119
|
+
if editor_box["on"]:
|
|
4120
|
+
_close_editor()
|
|
4121
|
+
return
|
|
4122
|
+
if running["on"] or approval_box.get("event") is not None:
|
|
4123
|
+
_soft_interrupt()
|
|
4124
|
+
return
|
|
4125
|
+
if copy_selection:
|
|
4126
|
+
selected = _current_transcript_selection()
|
|
4127
|
+
if selected:
|
|
4128
|
+
selection.update({"anchor": None, "active": None, "dragging": False})
|
|
4129
|
+
threading.Thread(
|
|
4130
|
+
target=_copy_transcript_selection,
|
|
4131
|
+
args=(selected,),
|
|
4132
|
+
daemon=True,
|
|
4133
|
+
).start()
|
|
4134
|
+
event.app.invalidate()
|
|
4135
|
+
return
|
|
4136
|
+
event.app.exit(result=None)
|
|
4137
|
+
|
|
4138
|
+
@kb.add("c-c")
|
|
4139
|
+
def _copy_interrupt_or_exit(event: Any) -> None:
|
|
4140
|
+
_interrupt_or_exit(event, copy_selection=True)
|
|
4141
|
+
|
|
4142
|
+
@kb.add("c-d")
|
|
4143
|
+
def _eof_or_exit(event: Any) -> None:
|
|
4144
|
+
_interrupt_or_exit(event, copy_selection=False)
|
|
4145
|
+
|
|
4146
|
+
@kb.add(
|
|
4147
|
+
"c-b",
|
|
4148
|
+
filter=_input_focused & _has_children & ~_completing,
|
|
4149
|
+
eager=True,
|
|
4150
|
+
)
|
|
4151
|
+
def _previous_subagent(event: Any) -> None:
|
|
4152
|
+
_navigate_subagent(-1)
|
|
4153
|
+
event.app.invalidate()
|
|
4154
|
+
|
|
4155
|
+
@kb.add(
|
|
4156
|
+
"c-n",
|
|
4157
|
+
filter=_input_focused & _has_children & ~_completing,
|
|
4158
|
+
eager=True,
|
|
4159
|
+
)
|
|
4160
|
+
def _next_subagent(event: Any) -> None:
|
|
4161
|
+
_navigate_subagent(1)
|
|
4162
|
+
event.app.invalidate()
|
|
4163
|
+
|
|
4164
|
+
@kb.add(
|
|
4165
|
+
"escape",
|
|
4166
|
+
filter=_input_focused
|
|
4167
|
+
& _subagent_panel_open
|
|
4168
|
+
& ~_small_modal_open
|
|
4169
|
+
& ~_completing
|
|
4170
|
+
& ~_config_open,
|
|
4171
|
+
eager=True,
|
|
4172
|
+
)
|
|
4173
|
+
def _close_subagent_panel(event: Any) -> None:
|
|
4174
|
+
_close_selected_subagent()
|
|
4175
|
+
event.app.invalidate()
|
|
4176
|
+
|
|
4177
|
+
# ---- in-TUI editor keys (only while the editor float is open) ----
|
|
4178
|
+
@kb.add("c-s", filter=_editor_open)
|
|
4179
|
+
def _editor_save_key(event: Any) -> None:
|
|
4180
|
+
_editor_save()
|
|
4181
|
+
|
|
4182
|
+
@kb.add("escape", filter=_editor_open) # non-eager so arrow ESC-sequences parse
|
|
4183
|
+
def _editor_cancel_key(event: Any) -> None:
|
|
4184
|
+
_close_editor()
|
|
4185
|
+
|
|
4186
|
+
@kb.add(
|
|
4187
|
+
"escape",
|
|
4188
|
+
filter=_turn_active
|
|
4189
|
+
& ~_help_open
|
|
4190
|
+
& ~_picker_open
|
|
4191
|
+
& ~_editor_open
|
|
4192
|
+
& ~_completing
|
|
4193
|
+
& ~_config_open
|
|
4194
|
+
& ~_subagent_panel_open,
|
|
4195
|
+
eager=True,
|
|
4196
|
+
)
|
|
4197
|
+
def _escape_interrupt(event: Any) -> None:
|
|
4198
|
+
# Esc interrupts a running turn immediately (never exits the app). Gated to a
|
|
4199
|
+
# live turn so it doesn't shadow Esc's other roles (cancel completion, close
|
|
4200
|
+
# help/picker, Alt+Enter newline).
|
|
4201
|
+
_soft_interrupt()
|
|
4202
|
+
|
|
4203
|
+
@kb.add(
|
|
4204
|
+
"enter",
|
|
4205
|
+
filter=_input_focused & ~_small_modal_open & ~_config_open,
|
|
4206
|
+
eager=True,
|
|
4207
|
+
)
|
|
4208
|
+
def _submit_key(event: Any) -> None:
|
|
4209
|
+
# When the slash dropdown has a highlighted row, Enter accepts it instead
|
|
4210
|
+
# of submitting (so you can pick a command, then edit args / submit). With
|
|
4211
|
+
# the menu merely open but nothing highlighted, Enter submits as normal.
|
|
4212
|
+
buff = input_area.buffer
|
|
4213
|
+
state = buff.complete_state
|
|
4214
|
+
if state is not None and state.current_completion is not None:
|
|
4215
|
+
buff.apply_completion(state.current_completion)
|
|
4216
|
+
return
|
|
4217
|
+
_submit()
|
|
4218
|
+
|
|
4219
|
+
@kb.add("c-j", filter=_input_focused & ~_small_modal_open & ~_config_open)
|
|
4220
|
+
@kb.add("escape", "enter", filter=_input_focused & ~_small_modal_open & ~_config_open)
|
|
4221
|
+
def _newline(event: Any) -> None:
|
|
4222
|
+
input_area.buffer.insert_text("\n")
|
|
4223
|
+
|
|
4224
|
+
@kb.add(
|
|
4225
|
+
"c-q",
|
|
4226
|
+
filter=_input_focused & ~_small_modal_open & ~_config_open,
|
|
4227
|
+
eager=True,
|
|
4228
|
+
)
|
|
4229
|
+
def _queue_key(event: Any) -> None:
|
|
4230
|
+
# Enter and Shift+Enter are byte-identical in ordinary terminals unless
|
|
4231
|
+
# an extended keyboard protocol is negotiated. Ctrl+Q is distinct, and
|
|
4232
|
+
# prompt_toolkit raw mode disables IXON so it is not swallowed as XOFF.
|
|
4233
|
+
_submit(queue_instead=True)
|
|
4234
|
+
|
|
4235
|
+
# ---- slash-command dropdown (only while the input is focused) ----
|
|
4236
|
+
@kb.add(
|
|
4237
|
+
"tab",
|
|
4238
|
+
filter=_input_focused & ~_help_open & ~_approval_pending & ~_config_open,
|
|
4239
|
+
eager=True,
|
|
4240
|
+
)
|
|
4241
|
+
def _complete_tab(event: Any) -> None:
|
|
4242
|
+
# Tab opens the dropdown for a "/…" line (selecting the first row) and
|
|
4243
|
+
# cycles through it on repeats. On an EMPTY input it instead cycles the
|
|
4244
|
+
# persona (Kilo/OpenCode-style); non-empty non-slash text swallows Tab
|
|
4245
|
+
# so a stray press mid-sentence never switches anything.
|
|
4246
|
+
buff = input_area.buffer
|
|
4247
|
+
if buff.complete_state is not None:
|
|
4248
|
+
buff.complete_next()
|
|
4249
|
+
elif buff.document.text_before_cursor.lstrip().startswith("/"):
|
|
4250
|
+
buff.start_completion(select_first=True)
|
|
4251
|
+
elif persona_cycle is not None and not buff.document.text.strip():
|
|
4252
|
+
if running["on"]:
|
|
4253
|
+
transcript.append("warn", block_message("/persona"))
|
|
4254
|
+
_safe_invalidate()
|
|
4255
|
+
return
|
|
4256
|
+
try:
|
|
4257
|
+
messages = persona_cycle()
|
|
4258
|
+
except Exception as exc: # noqa: BLE001 - never crash the UI on Tab
|
|
4259
|
+
messages = [("error", f"persona cycle failed: {exc}")]
|
|
4260
|
+
for role, text in messages or []:
|
|
4261
|
+
transcript.append(role, text)
|
|
4262
|
+
_safe_invalidate()
|
|
4263
|
+
|
|
4264
|
+
@kb.add("down", filter=_input_focused & _completing, eager=True)
|
|
4265
|
+
def _complete_down(event: Any) -> None:
|
|
4266
|
+
input_area.buffer.complete_next()
|
|
4267
|
+
|
|
4268
|
+
@kb.add("up", filter=_input_focused & _completing, eager=True)
|
|
4269
|
+
def _complete_up(event: Any) -> None:
|
|
4270
|
+
input_area.buffer.complete_previous()
|
|
4271
|
+
|
|
4272
|
+
@kb.add("escape", filter=_input_focused & _completing, eager=True)
|
|
4273
|
+
def _complete_cancel(event: Any) -> None:
|
|
4274
|
+
input_area.buffer.cancel_completion()
|
|
4275
|
+
|
|
4276
|
+
# ---- /help popup keys (only while the popup is open) ----
|
|
4277
|
+
@kb.add("escape", filter=_help_open)
|
|
4278
|
+
def _help_escape(event: Any) -> None:
|
|
4279
|
+
_close_help()
|
|
4280
|
+
|
|
4281
|
+
@kb.add("enter", filter=_help_open, eager=True)
|
|
4282
|
+
def _help_confirm(event: Any) -> None:
|
|
4283
|
+
# Enter closes the panel; for a confirm panel it also acts. The callback
|
|
4284
|
+
# callback form takes precedence; otherwise the stored command runs
|
|
4285
|
+
# (e.g. the /forge intro).
|
|
4286
|
+
# Esc/q cancel. on_confirm returns either a list of (role, text) rows to
|
|
4287
|
+
# echo, or a dict {"messages"?: [...], "picker"?: {...}} whose ``picker``
|
|
4288
|
+
# chains a follow-up selection popup (e.g. the /forge intro → "Use the
|
|
4289
|
+
# planner?" prompt before entering Forge).
|
|
4290
|
+
confirm = help_box.get("confirm")
|
|
4291
|
+
on_confirm = help_box.get("on_confirm")
|
|
4292
|
+
_close_help()
|
|
4293
|
+
if on_confirm is not None:
|
|
4294
|
+
try:
|
|
4295
|
+
result = on_confirm()
|
|
4296
|
+
except Exception as exc: # noqa: BLE001 - never crash the UI on confirm
|
|
4297
|
+
transcript.append("error", f"Confirm action failed: {exc}")
|
|
4298
|
+
result = None
|
|
4299
|
+
messages = result
|
|
4300
|
+
picker_spec: dict[str, Any] | None = None
|
|
4301
|
+
if isinstance(result, dict):
|
|
4302
|
+
messages = result.get("messages")
|
|
4303
|
+
picker_spec = result.get("picker")
|
|
4304
|
+
for role, text in messages or []:
|
|
4305
|
+
transcript.append(role, text)
|
|
4306
|
+
if picker_spec:
|
|
4307
|
+
_open_picker(picker_spec)
|
|
4308
|
+
_safe_invalidate()
|
|
4309
|
+
return
|
|
4310
|
+
if confirm:
|
|
4311
|
+
_dispatch_command(str(confirm))
|
|
4312
|
+
|
|
4313
|
+
@kb.add("q", filter=_help_open, eager=True)
|
|
4314
|
+
def _help_dismiss(event: Any) -> None:
|
|
4315
|
+
# q is always a plain dismiss — i.e. cancel for a confirm panel.
|
|
4316
|
+
_close_help()
|
|
4317
|
+
|
|
4318
|
+
@kb.add("up", filter=_help_open, eager=True)
|
|
4319
|
+
@kb.add("k", filter=_help_open, eager=True)
|
|
4320
|
+
def _help_scroll_up(event: Any) -> None:
|
|
4321
|
+
_help_scroll(-1)
|
|
4322
|
+
|
|
4323
|
+
@kb.add("down", filter=_help_open, eager=True)
|
|
4324
|
+
@kb.add("j", filter=_help_open, eager=True)
|
|
4325
|
+
def _help_scroll_down(event: Any) -> None:
|
|
4326
|
+
_help_scroll(1)
|
|
4327
|
+
|
|
4328
|
+
@kb.add("home", filter=_help_open, eager=True)
|
|
4329
|
+
def _help_to_top(event: Any) -> None:
|
|
4330
|
+
help_box["offset"] = 0
|
|
4331
|
+
_safe_invalidate()
|
|
4332
|
+
|
|
4333
|
+
@kb.add("end", filter=_help_open, eager=True)
|
|
4334
|
+
def _help_to_bottom(event: Any) -> None:
|
|
4335
|
+
help_box["offset"] = _help_follow_top()
|
|
4336
|
+
_safe_invalidate()
|
|
4337
|
+
|
|
4338
|
+
# ---- picker popup keys (only while a picker is open) ----
|
|
4339
|
+
@kb.add("escape", filter=_picker_open, eager=True)
|
|
4340
|
+
@kb.add("q", filter=_picker_open, eager=True)
|
|
4341
|
+
def _picker_cancel(event: Any) -> None:
|
|
4342
|
+
_close_picker()
|
|
4343
|
+
|
|
4344
|
+
@kb.add("up", filter=_picker_open, eager=True)
|
|
4345
|
+
@kb.add("k", filter=_picker_open, eager=True)
|
|
4346
|
+
def _picker_up(event: Any) -> None:
|
|
4347
|
+
_picker_move(-1)
|
|
4348
|
+
|
|
4349
|
+
@kb.add("down", filter=_picker_open, eager=True)
|
|
4350
|
+
@kb.add("j", filter=_picker_open, eager=True)
|
|
4351
|
+
def _picker_down(event: Any) -> None:
|
|
4352
|
+
_picker_move(1)
|
|
4353
|
+
|
|
4354
|
+
@kb.add("enter", filter=_picker_open, eager=True)
|
|
4355
|
+
def _picker_enter(event: Any) -> None:
|
|
4356
|
+
_picker_choose(int(picker_box["index"]))
|
|
4357
|
+
|
|
4358
|
+
# Number keys pick (and apply) the matching option directly.
|
|
4359
|
+
@kb.add("1", filter=_picker_open, eager=True)
|
|
4360
|
+
@kb.add("2", filter=_picker_open, eager=True)
|
|
4361
|
+
@kb.add("3", filter=_picker_open, eager=True)
|
|
4362
|
+
@kb.add("4", filter=_picker_open, eager=True)
|
|
4363
|
+
@kb.add("5", filter=_picker_open, eager=True)
|
|
4364
|
+
@kb.add("6", filter=_picker_open, eager=True)
|
|
4365
|
+
@kb.add("7", filter=_picker_open, eager=True)
|
|
4366
|
+
@kb.add("8", filter=_picker_open, eager=True)
|
|
4367
|
+
@kb.add("9", filter=_picker_open, eager=True)
|
|
4368
|
+
def _picker_digit(event: Any) -> None:
|
|
4369
|
+
try:
|
|
4370
|
+
_picker_choose(int(event.data) - 1)
|
|
4371
|
+
except (TypeError, ValueError):
|
|
4372
|
+
pass
|
|
4373
|
+
|
|
4374
|
+
@kb.add("s-tab", filter=~_config_open, eager=True)
|
|
4375
|
+
def _cycle_exec_mode(event: Any) -> None:
|
|
4376
|
+
# Shift+Tab advances the execution mode (read → safe → fast → full →
|
|
4377
|
+
# read), the same primitive as the /mode picker. The callback owns the
|
|
4378
|
+
# session mutation and returns the rows to echo — including the
|
|
4379
|
+
# fullaccess warning, so landing on the unguarded mode is never silent.
|
|
4380
|
+
# Without a callback (Phase 1 shell, tests) the key is inert rather than
|
|
4381
|
+
# flipping a footer field the runtime would not honour.
|
|
4382
|
+
if mode_cycle is None:
|
|
4383
|
+
return
|
|
4384
|
+
try:
|
|
4385
|
+
messages = mode_cycle()
|
|
4386
|
+
except Exception as exc: # noqa: BLE001 - never crash the UI on Shift+Tab
|
|
4387
|
+
messages = [("error", f"mode cycle failed: {exc}")]
|
|
4388
|
+
for role, text in messages or []:
|
|
4389
|
+
transcript.append(role, text)
|
|
4390
|
+
_safe_invalidate()
|
|
4391
|
+
|
|
4392
|
+
@kb.add("y", filter=_approval_pending, eager=True)
|
|
4393
|
+
def _approve_yes(event: Any) -> None:
|
|
4394
|
+
_resolve_approval(allow=True)
|
|
4395
|
+
|
|
4396
|
+
@kb.add("a", filter=_approval_pending, eager=True)
|
|
4397
|
+
def _approve_always(event: Any) -> None:
|
|
4398
|
+
_resolve_approval(allow=True, always=True)
|
|
4399
|
+
|
|
4400
|
+
@kb.add("n", filter=_approval_pending, eager=True)
|
|
4401
|
+
def _approve_no(event: Any) -> None:
|
|
4402
|
+
_resolve_approval(allow=False)
|
|
4403
|
+
|
|
4404
|
+
@kb.add("c-p", filter=~_config_open, eager=True)
|
|
4405
|
+
def _command_menu(event: Any) -> None:
|
|
4406
|
+
# Command menu placeholder (Phase 3).
|
|
4407
|
+
event.app.invalidate()
|
|
4408
|
+
|
|
4409
|
+
@kb.add("c-r", filter=~_config_open, eager=True)
|
|
4410
|
+
def _toggle_reasoning(event: Any) -> None:
|
|
4411
|
+
# Expand/collapse provider-generated reasoning-summary blocks.
|
|
4412
|
+
view["reasoning_expanded"] = not view["reasoning_expanded"]
|
|
4413
|
+
event.app.invalidate()
|
|
4414
|
+
|
|
4415
|
+
@kb.add("c-o", filter=~_config_open, eager=True)
|
|
4416
|
+
def _toggle_plan_meta(event: Any) -> None:
|
|
4417
|
+
# Expand/collapse Forge planner meta / plan-reconciliation note blocks.
|
|
4418
|
+
view["planmeta_expanded"] = not view["planmeta_expanded"]
|
|
4419
|
+
event.app.invalidate()
|
|
4420
|
+
|
|
4421
|
+
# ---- scrollback (works while the input keeps focus) ----
|
|
4422
|
+
@kb.add("pageup", filter=_help_open, eager=True)
|
|
4423
|
+
def _help_pageup(event: Any) -> None:
|
|
4424
|
+
_help_scroll(-_help_page_rows())
|
|
4425
|
+
|
|
4426
|
+
@kb.add("pagedown", filter=_help_open, eager=True)
|
|
4427
|
+
def _help_pagedown(event: Any) -> None:
|
|
4428
|
+
_help_scroll(_help_page_rows())
|
|
4429
|
+
|
|
4430
|
+
@kb.add(
|
|
4431
|
+
"pageup", filter=~_help_open & ~_picker_open & ~_editor_open & ~_config_open, eager=True
|
|
4432
|
+
)
|
|
4433
|
+
def _scroll_up(event: Any) -> None:
|
|
4434
|
+
_scroll_move(-_scroll_page_rows())
|
|
4435
|
+
event.app.invalidate()
|
|
4436
|
+
|
|
4437
|
+
@kb.add(
|
|
4438
|
+
"pagedown", filter=~_help_open & ~_picker_open & ~_editor_open & ~_config_open, eager=True
|
|
4439
|
+
)
|
|
4440
|
+
def _scroll_down(event: Any) -> None:
|
|
4441
|
+
_scroll_move(_scroll_page_rows())
|
|
4442
|
+
event.app.invalidate()
|
|
4443
|
+
|
|
4444
|
+
@kb.add(
|
|
4445
|
+
"c-home", filter=~_help_open & ~_picker_open & ~_editor_open & ~_config_open, eager=True
|
|
4446
|
+
)
|
|
4447
|
+
def _scroll_top(event: Any) -> None:
|
|
4448
|
+
scroll["follow"] = False
|
|
4449
|
+
scroll["offset"] = 0
|
|
4450
|
+
event.app.invalidate()
|
|
4451
|
+
|
|
4452
|
+
@kb.add("c-end", filter=~_help_open & ~_picker_open & ~_editor_open & ~_config_open, eager=True)
|
|
4453
|
+
def _scroll_bottom(event: Any) -> None:
|
|
4454
|
+
scroll["follow"] = True
|
|
4455
|
+
event.app.invalidate()
|
|
4456
|
+
|
|
4457
|
+
# The /config overlay adds its own key bindings (gated on its open state) and
|
|
4458
|
+
# needs the setup wizard's style classes merged in for its panels to render.
|
|
4459
|
+
_app_style = _build_tui_style(terminal_theme)
|
|
4460
|
+
if config_overlay is not None:
|
|
4461
|
+
from .setup_app import _SETUP_STYLE
|
|
4462
|
+
|
|
4463
|
+
config_overlay.register(kb)
|
|
4464
|
+
_app_style = merge_styles([_app_style, _SETUP_STYLE])
|
|
4465
|
+
|
|
4466
|
+
tui_input, owned_tui_input = _resolve_tui_input(input)
|
|
4467
|
+
app: Application = Application(
|
|
4468
|
+
layout=Layout(root_container, focused_element=input_area),
|
|
4469
|
+
key_bindings=kb,
|
|
4470
|
+
style=_app_style,
|
|
4471
|
+
full_screen=True,
|
|
4472
|
+
# Keep mouse reporting enabled so wheel events and drag selection are both
|
|
4473
|
+
# handled by the virtualized transcript. Completed selections are copied
|
|
4474
|
+
# automatically; Alysis Code does not claim a copy key binding.
|
|
4475
|
+
mouse_support=True,
|
|
4476
|
+
cursor=CursorShape.BEAM,
|
|
4477
|
+
input=tui_input,
|
|
4478
|
+
output=output,
|
|
4479
|
+
# Cap repaints at ~30 FPS. A streaming turn invalidates on every token
|
|
4480
|
+
# (transcript._touch → invalidate), which otherwise pins the terminal at
|
|
4481
|
+
# the back-to-back repaint rate — a flood of near-full-screen diffs that
|
|
4482
|
+
# tears and lags on a slower pty (e.g. WSL → Windows Terminal). Coalescing
|
|
4483
|
+
# the token flood into a steady cadence keeps streaming smooth. Kept above
|
|
4484
|
+
# the 0.1s spinner/clock cadence so "thinking…" still animates. Pairs with
|
|
4485
|
+
# the memoized markdown render, which makes each repaint cheap enough for
|
|
4486
|
+
# the cap to actually bite (a redraw slower than the interval defeats it).
|
|
4487
|
+
min_redraw_interval=0.033,
|
|
4488
|
+
)
|
|
4489
|
+
|
|
4490
|
+
# ---- animation driver: owl while idle, spinner while a turn runs ----
|
|
4491
|
+
def _pre_run() -> None:
|
|
4492
|
+
if open_config_on_start and config_overlay is not None:
|
|
4493
|
+
config_overlay.open()
|
|
4494
|
+
|
|
4495
|
+
def _spin() -> None:
|
|
4496
|
+
while getattr(app, "is_running", False):
|
|
4497
|
+
time.sleep(0.1)
|
|
4498
|
+
animated = False
|
|
4499
|
+
if owl.available and not _conversation_started():
|
|
4500
|
+
owl.advance()
|
|
4501
|
+
animated = True
|
|
4502
|
+
if running["on"] or transcript.status:
|
|
4503
|
+
spinner["i"] = (spinner["i"] + 1) % len(_SPINNER_FRAMES)
|
|
4504
|
+
animated = True
|
|
4505
|
+
scheduler = getattr(session, "child_scheduler", None)
|
|
4506
|
+
if _poll_selected_subagent(
|
|
4507
|
+
subagent_panel,
|
|
4508
|
+
scheduler,
|
|
4509
|
+
on_failure=_report_subagent_poll_failure,
|
|
4510
|
+
):
|
|
4511
|
+
animated = True
|
|
4512
|
+
if config_overlay is not None and config_overlay.is_busy():
|
|
4513
|
+
config_overlay.tick_spinner()
|
|
4514
|
+
animated = True
|
|
4515
|
+
if animated:
|
|
4516
|
+
try:
|
|
4517
|
+
app.invalidate()
|
|
4518
|
+
except Exception:
|
|
4519
|
+
break
|
|
4520
|
+
|
|
4521
|
+
threading.Thread(target=_spin, daemon=True).start()
|
|
4522
|
+
|
|
4523
|
+
try:
|
|
4524
|
+
result = app.run(pre_run=_pre_run)
|
|
4525
|
+
finally:
|
|
4526
|
+
if owned_tui_input is not None:
|
|
4527
|
+
owned_tui_input.close()
|
|
4528
|
+
|
|
4529
|
+
# Unwind any in-flight turn before returning so the caller can close the
|
|
4530
|
+
# session safely (no teardown racing a live worker). Cancel the turn, release
|
|
4531
|
+
# a parked approval wait, then join with a bounded timeout (the worker is a
|
|
4532
|
+
# daemon, so a stuck long-running tool cannot block process exit).
|
|
4533
|
+
token = cancel_box.get("token")
|
|
4534
|
+
if token is not None:
|
|
4535
|
+
token.cancel()
|
|
4536
|
+
pending = approval_box.get("event")
|
|
4537
|
+
if pending is not None:
|
|
4538
|
+
approval_box["decision"] = ApprovalDecision(allow=False)
|
|
4539
|
+
pending.set()
|
|
4540
|
+
worker = worker_box.get("thread")
|
|
4541
|
+
if worker is not None and worker.is_alive():
|
|
4542
|
+
worker.join(timeout=5.0)
|
|
4543
|
+
scheduler = getattr(session, "child_scheduler", None)
|
|
4544
|
+
set_lifecycle_listener = getattr(scheduler, "set_lifecycle_listener", None)
|
|
4545
|
+
if callable(set_lifecycle_listener):
|
|
4546
|
+
set_lifecycle_listener(None)
|
|
4547
|
+
|
|
4548
|
+
return result, transcript.entries
|
|
4549
|
+
|
|
4550
|
+
|
|
4551
|
+
__all__ = ["run_tui"]
|