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