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.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. 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"]