aru-code 0.41.0__tar.gz → 0.44.0__tar.gz

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 (185) hide show
  1. {aru_code-0.41.0/aru_code.egg-info → aru_code-0.44.0}/PKG-INFO +1 -1
  2. aru_code-0.44.0/aru/__init__.py +1 -0
  3. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/app.py +131 -0
  4. aru_code-0.44.0/aru/tui/sanitize.py +76 -0
  5. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/choice.py +20 -3
  6. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/confirm.py +4 -1
  7. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/text_input.py +4 -1
  8. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/chat.py +262 -30
  9. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/inline_choice.py +10 -3
  10. {aru_code-0.41.0 → aru_code-0.44.0/aru_code.egg-info}/PKG-INFO +1 -1
  11. {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/SOURCES.txt +1 -0
  12. {aru_code-0.41.0 → aru_code-0.44.0}/pyproject.toml +1 -1
  13. aru_code-0.41.0/aru/__init__.py +0 -1
  14. {aru_code-0.41.0 → aru_code-0.44.0}/LICENSE +0 -0
  15. {aru_code-0.41.0 → aru_code-0.44.0}/README.md +0 -0
  16. {aru_code-0.41.0 → aru_code-0.44.0}/aru/agent_factory.py +0 -0
  17. {aru_code-0.41.0 → aru_code-0.44.0}/aru/agents/__init__.py +0 -0
  18. {aru_code-0.41.0 → aru_code-0.44.0}/aru/agents/base.py +0 -0
  19. {aru_code-0.41.0 → aru_code-0.44.0}/aru/agents/catalog.py +0 -0
  20. {aru_code-0.41.0 → aru_code-0.44.0}/aru/agents/planner.py +0 -0
  21. {aru_code-0.41.0 → aru_code-0.44.0}/aru/cache_patch.py +0 -0
  22. {aru_code-0.41.0 → aru_code-0.44.0}/aru/checkpoints.py +0 -0
  23. {aru_code-0.41.0 → aru_code-0.44.0}/aru/cli.py +0 -0
  24. {aru_code-0.41.0 → aru_code-0.44.0}/aru/commands.py +0 -0
  25. {aru_code-0.41.0 → aru_code-0.44.0}/aru/completers.py +0 -0
  26. {aru_code-0.41.0 → aru_code-0.44.0}/aru/config.py +0 -0
  27. {aru_code-0.41.0 → aru_code-0.44.0}/aru/context.py +0 -0
  28. {aru_code-0.41.0 → aru_code-0.44.0}/aru/display.py +0 -0
  29. {aru_code-0.41.0 → aru_code-0.44.0}/aru/events.py +0 -0
  30. {aru_code-0.41.0 → aru_code-0.44.0}/aru/format/__init__.py +0 -0
  31. {aru_code-0.41.0 → aru_code-0.44.0}/aru/format/manager.py +0 -0
  32. {aru_code-0.41.0 → aru_code-0.44.0}/aru/format/runner.py +0 -0
  33. {aru_code-0.41.0 → aru_code-0.44.0}/aru/history_blocks.py +0 -0
  34. {aru_code-0.41.0 → aru_code-0.44.0}/aru/lsp/__init__.py +0 -0
  35. {aru_code-0.41.0 → aru_code-0.44.0}/aru/lsp/client.py +0 -0
  36. {aru_code-0.41.0 → aru_code-0.44.0}/aru/lsp/manager.py +0 -0
  37. {aru_code-0.41.0 → aru_code-0.44.0}/aru/lsp/protocol.py +0 -0
  38. {aru_code-0.41.0 → aru_code-0.44.0}/aru/memory/__init__.py +0 -0
  39. {aru_code-0.41.0 → aru_code-0.44.0}/aru/memory/extractor.py +0 -0
  40. {aru_code-0.41.0 → aru_code-0.44.0}/aru/memory/loader.py +0 -0
  41. {aru_code-0.41.0 → aru_code-0.44.0}/aru/memory/store.py +0 -0
  42. {aru_code-0.41.0 → aru_code-0.44.0}/aru/permissions.py +0 -0
  43. {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugin_cache.py +0 -0
  44. {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/__init__.py +0 -0
  45. {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/custom_tools.py +0 -0
  46. {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/hooks.py +0 -0
  47. {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/manager.py +0 -0
  48. {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/tool_api.py +0 -0
  49. {aru_code-0.41.0 → aru_code-0.44.0}/aru/providers.py +0 -0
  50. {aru_code-0.41.0 → aru_code-0.44.0}/aru/runner.py +0 -0
  51. {aru_code-0.41.0 → aru_code-0.44.0}/aru/runtime.py +0 -0
  52. {aru_code-0.41.0 → aru_code-0.44.0}/aru/select.py +0 -0
  53. {aru_code-0.41.0 → aru_code-0.44.0}/aru/session.py +0 -0
  54. {aru_code-0.41.0 → aru_code-0.44.0}/aru/sinks.py +0 -0
  55. {aru_code-0.41.0 → aru_code-0.44.0}/aru/streaming.py +0 -0
  56. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tool_policy.py +0 -0
  57. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/__init__.py +0 -0
  58. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/_diff.py +0 -0
  59. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/_shared.py +0 -0
  60. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/apply_patch.py +0 -0
  61. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/apply_patch_prompt.txt +0 -0
  62. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/ast_tools.py +0 -0
  63. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/codebase.py +0 -0
  64. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/delegate.py +0 -0
  65. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/delegate_prompt.txt +0 -0
  66. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/file_ops.py +0 -0
  67. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/gitignore.py +0 -0
  68. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/lsp.py +0 -0
  69. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/mcp_client.py +0 -0
  70. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/memory_tool.py +0 -0
  71. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/plan_mode.py +0 -0
  72. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/ranker.py +0 -0
  73. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/registry.py +0 -0
  74. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/search.py +0 -0
  75. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/shell.py +0 -0
  76. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/skill.py +0 -0
  77. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/tasklist.py +0 -0
  78. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/web.py +0 -0
  79. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/worktree.py +0 -0
  80. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/__init__.py +0 -0
  81. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/__init__.py +0 -0
  82. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/search.py +0 -0
  83. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/sinks.py +0 -0
  84. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/slash_bridge.py +0 -0
  85. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/ui.py +0 -0
  86. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/__init__.py +0 -0
  87. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/completer.py +0 -0
  88. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/context_pane.py +0 -0
  89. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/header.py +0 -0
  90. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/loaded_pane.py +0 -0
  91. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/status.py +0 -0
  92. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/thinking.py +0 -0
  93. {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/tools.py +0 -0
  94. {aru_code-0.41.0 → aru_code-0.44.0}/aru/ui.py +0 -0
  95. {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/dependency_links.txt +0 -0
  96. {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/entry_points.txt +0 -0
  97. {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/requires.txt +0 -0
  98. {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/top_level.txt +0 -0
  99. {aru_code-0.41.0 → aru_code-0.44.0}/setup.cfg +0 -0
  100. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_agents_base.py +0 -0
  101. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_agents_md_coverage.py +0 -0
  102. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_apply_patch.py +0 -0
  103. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_async_tool_permission.py +0 -0
  104. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cache_patch_metrics.py +0 -0
  105. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cache_patch_stop_reason.py +0 -0
  106. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_catalog.py +0 -0
  107. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_chat_scrollable.py +0 -0
  108. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_checkpoints.py +0 -0
  109. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli.py +0 -0
  110. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_advanced.py +0 -0
  111. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_base.py +0 -0
  112. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_completers.py +0 -0
  113. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_new.py +0 -0
  114. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_run_cli.py +0 -0
  115. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_session.py +0 -0
  116. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_shell.py +0 -0
  117. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_codebase.py +0 -0
  118. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_confabulation_regression.py +0 -0
  119. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_config.py +0 -0
  120. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_context.py +0 -0
  121. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_context_pane.py +0 -0
  122. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cwd_awareness.py +0 -0
  123. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_delegate.py +0 -0
  124. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_events_backward_compat.py +0 -0
  125. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_events_schema.py +0 -0
  126. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_fork_ctx_concurrency.py +0 -0
  127. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_format.py +0 -0
  128. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_gitignore.py +0 -0
  129. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_guardrails_scenarios.py +0 -0
  130. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_invoke_skill.py +0 -0
  131. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_invoked_skills.py +0 -0
  132. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_loaded_pane_path.py +0 -0
  133. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_lsp.py +0 -0
  134. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_lsp_rename.py +0 -0
  135. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_main.py +0 -0
  136. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_markdown_to_text.py +0 -0
  137. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_mcp_client.py +0 -0
  138. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_mcp_health.py +0 -0
  139. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_memory.py +0 -0
  140. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_memory_tool.py +0 -0
  141. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_microcompact.py +0 -0
  142. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_permissions.py +0 -0
  143. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plan_mode_refactor.py +0 -0
  144. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plugin_cache.py +0 -0
  145. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plugin_errors.py +0 -0
  146. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plugin_hooks_v2.py +0 -0
  147. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plugins.py +0 -0
  148. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_providers.py +0 -0
  149. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_ranker.py +0 -0
  150. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_reasoning.py +0 -0
  151. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_runner_interrupt.py +0 -0
  152. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_runner_recovery.py +0 -0
  153. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_runtime.py +0 -0
  154. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_select.py +0 -0
  155. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_skill_disallowed_tools.py +0 -0
  156. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_status_breakdown.py +0 -0
  157. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_status_cost.py +0 -0
  158. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_streaming_sink.py +0 -0
  159. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tasklist.py +0 -0
  160. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_thread_tool_timeout.py +0 -0
  161. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tool_policy.py +0 -0
  162. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_truncation_marker.py +0 -0
  163. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_app_boot.py +0 -0
  164. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_bindings.py +0 -0
  165. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_bus_flow.py +0 -0
  166. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_chat.py +0 -0
  167. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_chat_adversarial.py +0 -0
  168. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_completer.py +0 -0
  169. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_completer_dynamic.py +0 -0
  170. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_copy.py +0 -0
  171. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_input_behaviour.py +0 -0
  172. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_mention_expand.py +0 -0
  173. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_modals.py +0 -0
  174. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_mode_cycle.py +0 -0
  175. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_native_selection.py +0 -0
  176. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_permission_flow.py +0 -0
  177. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_plan_task_render.py +0 -0
  178. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_sidebar_toggle.py +0 -0
  179. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_slash_bridge.py +0 -0
  180. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_snapshot_smoke.py +0 -0
  181. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_thinking_and_boot.py +0 -0
  182. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_widgets_visual.py +0 -0
  183. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_ui_adapter.py +0 -0
  184. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_worktree.py +0 -0
  185. {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_worktree_session_restore.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.41.0
3
+ Version: 0.44.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.44.0"
@@ -256,6 +256,13 @@ class AruApp(App):
256
256
  "skills", "agents", "commands", "mcp", "yolo",
257
257
  }
258
258
 
259
+ # Layer 10 — interval (seconds) between belt-and-suspenders re-emits of
260
+ # the mouse-tracking enable sequences. 8s is the worst-case time-to-recover
261
+ # if a leaked DEC private-mode escape disables the wheel mid-turn; the
262
+ # cost is ~24 bytes per tick (four idempotent SGR sequences). See
263
+ # ``on_mount`` and ``_reenable_mouse_tracking`` for context.
264
+ _MOUSE_REENABLE_INTERVAL: float = 8.0
265
+
259
266
  def __init__(
260
267
  self,
261
268
  *,
@@ -375,6 +382,21 @@ class AruApp(App):
375
382
  if not self.is_headless:
376
383
  _push_terminal_title()
377
384
  _set_terminal_title(_compose_terminal_title(self.session))
385
+ # Layer 10 / 11 self-heal — periodic recovery of terminal state and
386
+ # input focus. Two failure classes share one tick:
387
+ # * mouse-enable lost (leaked DEC private-mode escape disabled the
388
+ # wheel) — re-emit ``_enable_mouse_support`` (Layer 10).
389
+ # * input focus / visibility lost (a focusable panel mounted by
390
+ # ``add_renderable`` grabbed focus, or an ``InlineChoicePrompt``
391
+ # left ``#input.-hidden`` stuck because its callback raised) —
392
+ # reassert the prompt as focused-and-visible (Layer 11).
393
+ # Both checks are idempotent on a healthy app and skipped under
394
+ # headless tests where there's no live driver to talk to.
395
+ if not self.is_headless:
396
+ self.set_interval(
397
+ self._MOUSE_REENABLE_INTERVAL,
398
+ self._self_heal_terminal_state,
399
+ )
378
400
 
379
401
  def _replay_resumed_history(self, chat: ChatPane) -> None:
380
402
  """Render a resumed session's user/assistant text back into the chat.
@@ -1068,6 +1090,115 @@ class AruApp(App):
1068
1090
  self.query_one(ContextPane).refresh_from_session()
1069
1091
  except Exception:
1070
1092
  pass
1093
+ # Layer 9 self-heal — re-assert Textual's mouse-tracking
1094
+ # sequences at the turn boundary. See ``_reenable_mouse_tracking``
1095
+ # for the rationale; here we eagerly recover the moment the
1096
+ # turn ends so the user's first post-turn scroll always works,
1097
+ # without waiting for the periodic Layer 10 tick.
1098
+ self._reenable_mouse_tracking()
1099
+
1100
+ def _reenable_mouse_tracking(self) -> None:
1101
+ """Re-emit Textual's mouse-tracking enable sequences (idempotent).
1102
+
1103
+ Calls the active driver's ``_enable_mouse_support`` which writes
1104
+ four short SGR sequences (``?1000h`` / ``?1003h`` / ``?1015h`` /
1105
+ ``?1006h``). They re-arm X10 mouse reporting at the terminal level
1106
+ so a leaked ``\\x1b[?1000l`` (or any other DEC private-mode escape
1107
+ that disabled wheel input) is recovered from automatically.
1108
+ Idempotent — sending an enable when tracking is already on is a
1109
+ documented no-op on every emulator.
1110
+ Called from two sites:
1111
+ * ``_run_turn`` finally-clause (Layer 9) — eager recovery at every
1112
+ turn boundary so the first post-turn scroll always works.
1113
+ * ``_self_heal_terminal_state`` periodic tick (Layer 10) — recovers
1114
+ mid-turn corruption (e.g. a leaked escape in panel content)
1115
+ within ``_MOUSE_REENABLE_INTERVAL`` instead of leaving the wheel
1116
+ dead until the (potentially long) turn finishes.
1117
+ Wrapped in ``try/except`` because the driver may be ``None`` in
1118
+ headless / test mode and the private method's exact name could
1119
+ shift in a future Textual; better to no-op silently than crash.
1120
+ """
1121
+ try:
1122
+ driver = self._driver
1123
+ if driver is not None:
1124
+ driver._enable_mouse_support()
1125
+ except Exception:
1126
+ pass
1127
+
1128
+ def _self_heal_terminal_state(self) -> None:
1129
+ """Periodic recovery of mouse tracking and input focus (Layers 10 + 11).
1130
+
1131
+ Two failure classes that the tick recovers from:
1132
+
1133
+ 1. **Terminal mouse-tracking lost.** Layer 9 already re-enables at
1134
+ the turn boundary; this catches mid-turn corruption so the
1135
+ wheel comes back within ``_MOUSE_REENABLE_INTERVAL`` instead
1136
+ of waiting for the agent to finish.
1137
+ 2. **Input prompt invisible or unfocused** when nothing else
1138
+ legitimately owns it. Three concrete scenarios this fixes:
1139
+ * an ``InlineChoicePrompt`` callback raised before
1140
+ ``on_unmount`` ran, leaving ``#input.-hidden`` stuck;
1141
+ * a focusable panel mounted by ``add_renderable`` (pre-Layer-11
1142
+ behaviour) grabbed focus and never released it;
1143
+ * an exception during ``finalize_assistant_message`` cancelled
1144
+ a focus-restore that ``_run_turn`` would normally do.
1145
+
1146
+ We only intervene when **no modal is on top** (modal owns input,
1147
+ ``len(self.screen_stack) <= 1``) and **no ``InlineChoicePrompt``
1148
+ is currently mounted** (the inline prompt legitimately steals
1149
+ focus and hides the input by design — touching it mid-flight
1150
+ would steal back from the user). When both conditions hold, we
1151
+ treat the input as the canonical focus target.
1152
+ """
1153
+ # Layer 10 — mouse tracking.
1154
+ self._reenable_mouse_tracking()
1155
+
1156
+ # Layer 11 — input watchdog. Skip if a modal is on top: the modal
1157
+ # is the legitimate input owner and the underlying ``Input`` is
1158
+ # not part of the active focus chain.
1159
+ try:
1160
+ if len(self.screen_stack) > 1:
1161
+ return
1162
+ except Exception:
1163
+ return
1164
+
1165
+ # Skip if an ``InlineChoicePrompt`` is currently mounted: it has
1166
+ # explicitly hidden the input and owns the focus while waiting
1167
+ # for the user's choice. ``query`` returns an empty list when the
1168
+ # widget tree has no match, so the truth-test is safe.
1169
+ try:
1170
+ from aru.tui.widgets.inline_choice import InlineChoicePrompt
1171
+ if list(self.query(InlineChoicePrompt)):
1172
+ return
1173
+ except Exception:
1174
+ pass
1175
+
1176
+ # Recover ``#input`` if it's stuck hidden (the ``-hidden`` class
1177
+ # comes off only inside ``InlineChoicePrompt._toggle_input``; if
1178
+ # that didn't run because the callback raised, the user is
1179
+ # stranded with no visible prompt). ``remove_class`` on a class
1180
+ # that isn't applied is a no-op, so the unconditional call is safe.
1181
+ try:
1182
+ inp = self.query_one(Input)
1183
+ except Exception:
1184
+ return
1185
+ try:
1186
+ if inp.has_class("-hidden"):
1187
+ inp.remove_class("-hidden")
1188
+ except Exception:
1189
+ pass
1190
+
1191
+ # Re-focus only when *nothing* currently has focus. We deliberately
1192
+ # do NOT yank focus away from a sidebar / scrollback / search
1193
+ # screen the user navigated to themselves — that would fight
1194
+ # legitimate keyboard navigation. The ``focused is None`` guard
1195
+ # narrows the recovery to the ghost-focus state we actually
1196
+ # observed in the bug.
1197
+ try:
1198
+ if self.screen.focused is None:
1199
+ inp.focus()
1200
+ except Exception:
1201
+ pass
1071
1202
 
1072
1203
  # ── Bus wiring — ToolsPane + StatusPane subscribe to plugin events ──
1073
1204
 
@@ -0,0 +1,76 @@
1
+ """Terminal-output hygiene helpers used across the TUI.
2
+
3
+ Layer 7 / Layer 9 / Layer 10 of the scroll-freeze post-mortem (see
4
+ ``aru/tui/widgets/chat.py`` for the full history) all hinge on the same
5
+ invariant: **no string content originating from the agent, a tool, or
6
+ arbitrary file content may reach the terminal with raw C0 control bytes
7
+ intact.** A single stray ``\\x1b[?1000l`` switches X10 mouse reporting off
8
+ on Windows ConPTY (and most modern emulators), at which point the wheel
9
+ stops working in every scroll surface in the app simultaneously.
10
+
11
+ The chat pane already protects everything that flows through
12
+ ``ChatMessageWidget.buffer`` and the ``add_renderable`` path, but modal
13
+ screens (``ChoiceModal`` / ``ConfirmModal`` / ``TextInputModal``)
14
+ historically built their ``Label`` / ``Static`` content from raw
15
+ agent-provided strings — including the diff preview shown when an edit
16
+ needs approval. Files containing escape bytes (colored scripts, captured
17
+ terminal output, accidentally-saved binaries) flowed straight through to
18
+ the terminal. Hence Layer 10: lift these helpers out of ``chat.py`` and
19
+ apply them at every modal composition point too.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+
27
+ # C0 controls (0x00-0x1F) and DEL (0x7F) are dropped on the way to the
28
+ # terminal, EXCEPT ``\n`` and ``\t`` which carry semantic meaning to
29
+ # markdown-it, Rich, and Textual layout. Implementation note:
30
+ # ``str.translate`` is implemented in C and runs in microseconds for
31
+ # multi-KB inputs — applying it at every boundary is essentially free.
32
+ _CTRL_CHAR_TRANSLATION: dict[int, None] = {
33
+ c: None for c in range(32) if chr(c) not in ("\n", "\t")
34
+ }
35
+ _CTRL_CHAR_TRANSLATION[0x7F] = None
36
+
37
+
38
+ def sanitize_for_terminal(raw: str) -> str:
39
+ """Remove non-printable C0 controls so rogue ANSI escapes can't reach the tty.
40
+
41
+ Keeps ``\\n`` and ``\\t``; drops everything else in the C0 range plus DEL.
42
+ Apply this at every boundary where externally-sourced text becomes a
43
+ Rich renderable / Textual widget content.
44
+ """
45
+ return raw.translate(_CTRL_CHAR_TRANSLATION)
46
+
47
+
48
+ class SanitizedRenderable:
49
+ """Wraps a Rich renderable so its output segments are stripped of C0 bytes.
50
+
51
+ ``sanitize_for_terminal`` covers every plain string we render. Arbitrary
52
+ Rich renderables (panels, diff previews, plan summaries, the startup
53
+ logo) skip that path and would mount as ``Static(renderable)`` whose
54
+ segments hit the compositor unmodified — including any rogue escapes
55
+ embedded in the inner text.
56
+
57
+ This wrapper closes that gap at the segment level: ``console.render``
58
+ yields segments from the inner renderable, we strip C0 bytes from any
59
+ segment whose ``.text`` contains them, and re-emit the cleaned stream.
60
+ Rich's ``Segment`` is a ``NamedTuple`` so ``seg._replace(text=...)`` is
61
+ a cheap immutable swap. Unchanged segments are re-emitted unmodified —
62
+ the hot path is a single ``str.translate`` per segment which typically
63
+ no-ops.
64
+ """
65
+
66
+ def __init__(self, inner: Any) -> None:
67
+ self._inner = inner
68
+
69
+ def __rich_console__(self, console: Any, options: Any) -> Any:
70
+ for seg in console.render(self._inner, options):
71
+ if seg.text:
72
+ clean = sanitize_for_terminal(seg.text)
73
+ if clean != seg.text:
74
+ yield seg._replace(text=clean)
75
+ continue
76
+ yield seg
@@ -12,6 +12,8 @@ from textual.screen import ModalScreen
12
12
  from textual.widgets import Label, OptionList, Static
13
13
  from textual.widgets.option_list import Option
14
14
 
15
+ from aru.tui.sanitize import SanitizedRenderable, sanitize_for_terminal
16
+
15
17
 
16
18
  class ChoiceModal(ModalScreen[int | None]):
17
19
  """Numbered option menu. ``dismiss(int)`` returns the chosen index.
@@ -74,12 +76,27 @@ class ChoiceModal(ModalScreen[int | None]):
74
76
  def compose(self) -> ComposeResult:
75
77
  with Vertical(id="choice-box"):
76
78
  if self._title:
77
- yield Label(self._title, id="choice-title")
79
+ # Sanitise: title may include agent-generated text (plan name,
80
+ # rejection reason, tool label) which can carry C0 escapes
81
+ # that would disable mouse tracking globally — see Layer 10
82
+ # in the chat.py post-mortem.
83
+ yield Label(
84
+ sanitize_for_terminal(self._title),
85
+ id="choice-title",
86
+ )
78
87
  if self._details is not None:
79
- yield Static(self._details, id="choice-details")
88
+ # ``details`` is the diff preview / plan summary panel.
89
+ # Diffs over file content readily contain escape bytes when
90
+ # the file does (colored scripts, captured terminal output),
91
+ # making this the most likely entry point for the bug
92
+ # the periodic re-enable timer recovers from.
93
+ yield Static(
94
+ SanitizedRenderable(self._details),
95
+ id="choice-details",
96
+ )
80
97
  yield OptionList(
81
98
  *[
82
- Option(label, id=str(i))
99
+ Option(sanitize_for_terminal(label), id=str(i))
83
100
  for i, label in enumerate(self._options)
84
101
  ],
85
102
  id="choice-options",
@@ -8,6 +8,8 @@ from textual.containers import Horizontal, Vertical
8
8
  from textual.screen import ModalScreen
9
9
  from textual.widgets import Button, Label
10
10
 
11
+ from aru.tui.sanitize import sanitize_for_terminal
12
+
11
13
 
12
14
  class ConfirmModal(ModalScreen[bool]):
13
15
  """Yes / No dialog. ``dismiss(True)`` on yes, ``dismiss(False)`` on no.
@@ -53,7 +55,8 @@ class ConfirmModal(ModalScreen[bool]):
53
55
 
54
56
  def compose(self) -> ComposeResult:
55
57
  with Vertical(id="confirm-box"):
56
- yield Label(self._prompt, id="confirm-prompt")
58
+ # Strip C0 controls — see Layer 10 in chat.py post-mortem.
59
+ yield Label(sanitize_for_terminal(self._prompt), id="confirm-prompt")
57
60
  with Horizontal(id="confirm-buttons"):
58
61
  yield Button(
59
62
  "Yes",
@@ -8,6 +8,8 @@ from textual.containers import Vertical
8
8
  from textual.screen import ModalScreen
9
9
  from textual.widgets import Input, Label
10
10
 
11
+ from aru.tui.sanitize import sanitize_for_terminal
12
+
11
13
 
12
14
  class TextInputModal(ModalScreen[str | None]):
13
15
  """Single-line text prompt. ``dismiss(str)`` on Enter, ``dismiss(None)``
@@ -49,7 +51,8 @@ class TextInputModal(ModalScreen[str | None]):
49
51
 
50
52
  def compose(self) -> ComposeResult:
51
53
  with Vertical(id="text-box"):
52
- yield Label(self._prompt, id="text-prompt")
54
+ # Strip C0 controls — see Layer 10 in chat.py post-mortem.
55
+ yield Label(sanitize_for_terminal(self._prompt), id="text-prompt")
53
56
  yield Input(
54
57
  value=self._default,
55
58
  placeholder=self._placeholder,
@@ -128,6 +128,223 @@ cost and layout cost on the Aru side. This bug is about the
128
128
  terminal's own private-mode state being corrupted from outside —
129
129
  entirely unrelated to how fast we render, and invisible to any
130
130
  latency benchmark. Treat as a seventh layer: output-hygiene.
131
+
132
+ ----
133
+
134
+ Post-mortem — "mouse wheel dead during heavy streaming" (2026-04-23,
135
+ ``fix/scroll-refinement``)
136
+ ---------------------------------------------------------------------
137
+ **Symptom:** mouse wheel over the ChatPane does nothing while the
138
+ agent is actively streaming / running tool batches. TAB to focus
139
+ the pane + arrow keys / PgUp / PgDn works fine. Asymmetric enough
140
+ that it felt like "the mouse lost focus" — not a freeze.
141
+
142
+ Reported against session ``final-fantasy-battle/.aru/sessions/
143
+ e9397dc3.json``.
144
+
145
+ **Original (incorrect) theory:** interaction between
146
+ ``self.anchor()`` (layer 4) and ``_scroll_up_for_pointer``. This
147
+ was based on a misreading of Textual's source — see the Layer 9
148
+ correction below. ``_scroll_up_for_pointer`` does *not* pass
149
+ ``release_anchor=False``; it defaults to ``True`` (``widget.py:3378``
150
+ → ``widget.py:2730``), so wheel-up already releases the anchor via
151
+ the framework. The ``on_mouse_scroll_up`` handler we added
152
+ (``ChatPane.on_mouse_scroll_up``) is therefore redundant with the
153
+ framework's own behaviour — a no-op on the happy path. It is kept
154
+ as defensive redundancy because removing it is the same shape of
155
+ change as keeping it, but it should not be credited for "fixing"
156
+ anything.
157
+
158
+ **What the bug probably was:** the same Layer-7 class of issue
159
+ that the next session surfaced again — a rogue DEC private-mode
160
+ escape reaching the terminal and disabling X10 mouse reporting.
161
+ See Layer 9 for the real signature and the robust fix.
162
+
163
+ ----
164
+
165
+ Post-mortem — "wheel globally dead at end of stream" (2026-04-24,
166
+ ``fix/scroll-refinement`` continued)
167
+ ---------------------------------------------------------------------
168
+ **Symptom:** immediately after a long streaming turn concluded,
169
+ mouse wheel stopped working on *every* scrollable surface in the
170
+ app — ChatPane, sidebars, modals — simultaneously. TAB to walk
171
+ focus into a scrollbar and arrow-key scrolling from there worked.
172
+ Classic Layer-7 fingerprint: terminal-level mouse reporting got
173
+ turned off.
174
+
175
+ Reported against session ``final-fantasy-battle3/.aru/sessions/
176
+ 7e9e4549.json``: one mega-turn with 120 tool calls interleaved
177
+ with 66 text blocks, 31 plan-panel mounts via
178
+ ``add_renderable(scrollable=True)``, ~245 widgets in the pane.
179
+
180
+ **What we could prove:** a byte-level scan of the saved session
181
+ for C0 control chars turned up zero ``\\x1b`` bytes. The leak is
182
+ either (a) from a path that isn't persisted to ``session.json``
183
+ (tool ``stdout``/``stderr`` never reaches the chat directly but
184
+ transient UI strings, skill output, or reasoning tokens might),
185
+ or (b) a Windows ConPTY quirk during high-volume redraw where the
186
+ driver's mouse-enable state drops without us emitting anything
187
+ hostile. Chasing the exact source is caça ao fantasma; the
188
+ mitigation is structural.
189
+
190
+ **Two-prong fix:**
191
+
192
+ 1. **Close the last unsanitised content path —
193
+ ``_SanitizedRenderable``.** ``ChatMessageWidget`` already
194
+ sanitises everything that goes through its ``buffer``. Arbitrary
195
+ Rich renderables handed to ``add_renderable`` (plan panels, task
196
+ lists, diff previews, the logo) bypass that path and mount as
197
+ ``Static(renderable)``. The wrapper sits between the renderable
198
+ and Rich's console, filtering C0 bytes out of every segment's
199
+ ``.text`` before it reaches Textual's compositor. Matches the
200
+ Layer 7 sanitisation boundary for the unchecked route.
201
+
202
+ 2. **Self-heal at turn boundary —
203
+ ``AruApp._run_turn`` finally clause.** Call the driver's
204
+ ``_enable_mouse_support()`` after each turn finishes. That
205
+ re-emits Textual's own four mouse-enable sequences (``?1000h``,
206
+ ``?1003h``, ``?1015h``, ``?1006h`` — see
207
+ ``textual/drivers/windows_driver.py:56``). Cost is four short
208
+ writes; benefit is full recovery of wheel input regardless of
209
+ what corrupted the terminal state mid-turn. Idempotent: a no-op
210
+ when mouse tracking was never disabled.
211
+
212
+ Treat as a ninth layer: defence-in-depth against terminal-state
213
+ corruption. Prong 1 plugs the last known-possible leak inside our
214
+ code; prong 2 recovers even if something outside our reach drops
215
+ the state anyway.
216
+
217
+ ----
218
+
219
+ Post-mortem — "wheel still dies after edits / option prompts" (2026-04-24,
220
+ ``fix/scroll-analysis`` continued)
221
+ ---------------------------------------------------------------------
222
+ **Symptom:** users reported that the wheel-dead bug reproduces most
223
+ often **right after a file edit was approved** or **while picking an
224
+ option in a modal**, rather than only at end-of-turn. Layers 7 + 9
225
+ already cover ``ChatMessageWidget.buffer`` and ``add_renderable``,
226
+ but the bug clearly fired through some content path neither of those
227
+ guarded.
228
+
229
+ **Cause:** the Layer 9 audit had a blind spot — the *modal screens*.
230
+ ``ChoiceModal`` (the approval prompt for plan / edit / permission),
231
+ ``ConfirmModal``, and ``TextInputModal`` all built their visible
232
+ content from raw caller-supplied strings:
233
+
234
+ * ``aru/tui/screens/choice.py:77`` — ``Label(self._title)``
235
+ * ``aru/tui/screens/choice.py:79`` — ``Static(self._details)``
236
+ (the **diff preview** for edit approvals)
237
+ * ``aru/tui/screens/confirm.py:56`` — ``Label(self._prompt)``
238
+ * ``aru/tui/screens/text_input.py:52`` — ``Label(self._prompt)``
239
+
240
+ The ``details`` panel of ``ChoiceModal`` is the obvious gun — it's
241
+ where the unified diff goes when the user is asked to approve an
242
+ ``edit_file``. Diffs over file content faithfully reproduce whatever
243
+ bytes were in the file; a colored shell script, a captured terminal
244
+ recording, or any binary-ish artifact saved as text trivially carries
245
+ ``\\x1b[?1000l`` straight into the diff and onto the terminal. That
246
+ matches the user's reported pattern exactly: "wheel dies after I
247
+ approve an edit".
248
+
249
+ **Two-prong fix:**
250
+
251
+ 1. **Lift the Layer 7/9 helpers into a shared module.**
252
+ ``aru/tui/sanitize.py`` now exports ``sanitize_for_terminal`` and
253
+ ``SanitizedRenderable``; ``chat.py`` imports them with the same
254
+ names it had locally so nothing inside this file changes
255
+ semantically. The four modal compose sites now apply the same
256
+ barrier — ``Label(sanitize_for_terminal(self._prompt))`` for
257
+ plain-text prompts and ``Static(SanitizedRenderable(self._details))``
258
+ for arbitrary renderables. Any future modal added to the TUI
259
+ should follow this convention; the helper module is the canonical
260
+ location.
261
+
262
+ 2. **Periodic mouse-tracking re-emit (``AruApp._reenable_mouse_tracking``).**
263
+ The Layer 9 turn-boundary recovery only fires after the agent
264
+ finishes. A diff preview shown mid-turn can disable the wheel for
265
+ minutes if the agent is doing a long batch of edits. The new
266
+ ``set_interval(_MOUSE_REENABLE_INTERVAL=8s, ...)`` in
267
+ ``on_mount`` re-emits the four enable sequences every eight
268
+ seconds regardless of turn state — ~24 bytes per tick, idempotent
269
+ on a healthy terminal. Worst-case time-to-recover is bounded at 8s
270
+ instead of "until the agent stops working".
271
+
272
+ Treat as a tenth layer. Layer 10 differs from 9 in scope: 9 plugs
273
+ known leaks at known boundaries, 10 assumes leaks will keep being
274
+ found (Textual stack, plugin renderables, a future modal) and recovers
275
+ on a clock independently of any code path noticing. The pair is the
276
+ intended steady state — not a bug-of-the-week, a structural answer to
277
+ a class of bug we cannot fully prevent without rewriting how arbitrary
278
+ renderables reach Rich's console.
279
+
280
+ ----
281
+
282
+ Post-mortem — "input loses focus mid-stream in YOLO" (2026-04-25,
283
+ ``fix/scroll-analysis`` continued)
284
+ ---------------------------------------------------------------------
285
+ **Symptom:** during long YOLO-mode runs (no permission prompts, no
286
+ modals), the user reported that the input box stops accepting
287
+ keystrokes mid-implementation. Often coincident with the wheel-dead
288
+ signature, but distinct: typing goes nowhere even before any visible
289
+ panel suggests focus moved.
290
+
291
+ **What it was not:** the Layer 10 audit was scoped to *terminal-state*
292
+ corruption (mouse tracking turned off by stray escape bytes). The
293
+ focus issue is a separate failure mode — the same content paths that
294
+ leak C0 bytes also mount focusable widgets, and Textual's default
295
+ focus chain happily includes them.
296
+
297
+ **Two compounding causes:**
298
+
299
+ 1. **``add_renderable(scrollable=True)`` mounted a focus-eligible
300
+ ``VerticalScroll``.** ``VerticalScroll.can_focus`` defaults to
301
+ ``True`` so users can Tab into a panel for keyboard scrolling.
302
+ Inside the chat flow, where every plan/task/diff render adds a
303
+ wrapper, this turns content panels into focus competitors with the
304
+ ``Input``. A single Tab during streaming, a focus restoration
305
+ after a modal closes, or any Textual-internal focus rotation could
306
+ land on a panel and leave the input dead. Fix: ``wrapper.can_focus
307
+ = False`` on every scrollable wrapper. Mouse-wheel scrolling inside
308
+ the panel still works because Textual routes wheel events via the
309
+ pointer, not the focus chain.
310
+
311
+ 2. **``InlineChoicePrompt`` had no recovery if its callback raised.**
312
+ The widget hides ``#input`` on mount and restores it on unmount.
313
+ If the ``on_choice`` callback throws (or the widget is removed by a
314
+ parent before lifecycle fires), ``-hidden`` stays applied and the
315
+ input is invisible until the next mount/unmount cycle — possibly
316
+ never. Layer 11 doesn't fix the underlying lifecycle issue
317
+ (callbacks should be exception-safe in their own right) but adds a
318
+ recovery loop: the periodic tick checks for stuck state and clears
319
+ it.
320
+
321
+ **Layer 11 — input watchdog, sharing the Layer 10 timer.**
322
+ ``AruApp._self_heal_terminal_state`` extends ``_reenable_mouse_tracking``
323
+ to also enforce input invariants when the inline-prompt path is not
324
+ legitimately active:
325
+
326
+ * If a modal screen is on top → skip (modal owns input).
327
+ * If an ``InlineChoicePrompt`` is mounted → skip (it owns focus by
328
+ design while waiting for a choice).
329
+ * Otherwise: clear stuck ``-hidden`` from ``#input`` and refocus it
330
+ iff ``screen.focused is None``. The ``focused is None`` guard is
331
+ intentional — it does NOT fight legitimate Tab navigation to the
332
+ sidebar / scrollback / search screen. We only recover from the
333
+ ghost-focus state where Textual's chain has nobody.
334
+
335
+ Layer 11 also extends the modal-sanitisation pattern to
336
+ ``InlineChoicePrompt``, which had been overlooked by Layer 10 — its
337
+ ``Label(self._title)`` / ``Option(label)`` calls now go through
338
+ ``sanitize_for_terminal`` before reaching the widget tree.
339
+
340
+ **Why we keep adding layers and not rewriting the architecture:** each
341
+ layer addresses a distinct *signal* the user reported — and each one
342
+ has narrow, idempotent recovery semantics. Rewriting the chat to use
343
+ a single virtualised text buffer (à la Textual's recent Markdown
344
+ virtualisation experiments) would close some of these by structure,
345
+ but at the cost of every other property the chat currently has
346
+ (selection, copy, mid-stream insertion of arbitrary Rich panels, plan
347
+ mounts). Layered defences are cheap and additive; the rewrite is not.
131
348
  """
132
349
 
133
350
  from __future__ import annotations
@@ -148,6 +365,11 @@ from textual.containers import VerticalScroll
148
365
  from textual.reactive import reactive
149
366
  from textual.widgets import Static
150
367
 
368
+ from aru.tui.sanitize import (
369
+ SanitizedRenderable as _SanitizedRenderable,
370
+ sanitize_for_terminal as _sanitize_for_terminal,
371
+ )
372
+
151
373
 
152
374
  # Reference-definition line: ``[label]: href`` (optional leading 0–3 spaces).
153
375
  # Presence of *any* reference definition anywhere in the snapshot disables the
@@ -157,31 +379,6 @@ from textual.widgets import Static
157
379
  _REF_DEF_RE = re.compile(r"^[ ]{0,3}\[[^\]\n]+\]:\s", re.MULTILINE)
158
380
 
159
381
 
160
- # Strip ASCII control characters (0x00–0x1F plus DEL 0x7F) from any content
161
- # about to be rendered to the terminal, EXCEPT ``\n`` and ``\t`` which are
162
- # semantically meaningful to markdown-it, Rich, and Textual. Rich's ``Text``
163
- # passes escape bytes through verbatim, so if a streamed model reply or a
164
- # tool label contains ``\x1b[?1000l`` (or any other DEC private-mode escape),
165
- # the terminal receives it directly and globally disables mouse tracking —
166
- # at which point every scroll area in the TUI stops responding to the
167
- # mouse wheel (keyboard still works, which is why the bug presents as
168
- # "only scroll froze"). Models that talk about terminal control sequences
169
- # or tools that echo subprocess output are the realistic injection paths.
170
- _CTRL_CHAR_TRANSLATION = {c: None for c in range(32) if chr(c) not in ("\n", "\t")}
171
- _CTRL_CHAR_TRANSLATION[0x7F] = None
172
-
173
-
174
- def _sanitize_for_terminal(raw: str) -> str:
175
- """Remove non-printable control chars so rogue ANSI escapes can't reach the tty.
176
-
177
- Keeps ``\\n`` and ``\\t``; drops everything else in the C0 range plus DEL.
178
- Cheap: ``str.translate`` is implemented in C and runs in microseconds for
179
- multi-KB inputs. Applied at every boundary where chat content becomes a
180
- Rich renderable.
181
- """
182
- return raw.translate(_CTRL_CHAR_TRANSLATION)
183
-
184
-
185
382
  def _scan_fences(text: str) -> tuple[int, int]:
186
383
  """One-pass fence scanner. Returns ``(last_stable_split, open_fence_start)``.
187
384
 
@@ -875,16 +1072,36 @@ class ChatPane(VerticalScroll):
875
1072
  # us enqueuing a ``scroll_end`` after every delta / tool event.
876
1073
  # (a) kills the ``call_after_refresh`` backlog that piled up when
877
1074
  # the UI thread was busy rendering markdown; (b) releases the anchor
878
- # when the user manually scrolls up, so they can read history
879
- # mid-stream without the viewport snapping back every 50 ms; and
880
- # (c) re-engages automatically when they return to the bottom via
881
- # ``_check_anchor``. Matches Textual's own "streaming Markdown"
1075
+ # when the user manually scrolls — wheel, keyboard, or drag all go
1076
+ # through ``_scroll_to`` which releases by default (widget.py:2730);
1077
+ # and (c) re-engages automatically when they return to the bottom
1078
+ # via ``_check_anchor``. Matches Textual's own "streaming Markdown"
882
1079
  # recipe (see ``Markdown.get_stream`` docstring).
883
1080
  self.anchor()
884
1081
  # Periodic flush; cheap because the reactive watcher already
885
1082
  # debounces repaints when buffer doesn't actually change.
886
1083
  self.set_interval(self.DEBOUNCE_SEC, self._flush_pending_delta)
887
1084
 
1085
+ def on_mouse_scroll_up(self, event) -> None:
1086
+ """Defensive redundancy — explicitly release the anchor on wheel-up.
1087
+
1088
+ Originally added under a misreading of Textual's source (see the
1089
+ Layer 8 correction in the module post-mortem). The framework's
1090
+ ``_scroll_up_for_pointer`` calls ``_scroll_to`` *without*
1091
+ ``release_anchor``, which defaults to ``True`` in
1092
+ ``widget.py:2730`` — so Textual already releases the anchor on
1093
+ wheel-up. This handler does the same thing one beat earlier and
1094
+ is effectively a no-op on the normal path.
1095
+
1096
+ Kept because (a) removing it has the same shape of change as
1097
+ keeping it and (b) if some future Textual refactor ever flips
1098
+ the default, this keeps wheel-up behaving the way ChatPane
1099
+ needs. No ``event.stop()`` — the framework handler still runs
1100
+ after this and does the actual scroll.
1101
+ """
1102
+ if self._anchored and not self._anchor_released:
1103
+ self.release_anchor()
1104
+
888
1105
  # ── API used by TextualBusSink and the App ────────────────────────
889
1106
 
890
1107
  def add_user_message(self, text: str) -> None:
@@ -924,7 +1141,11 @@ class ChatPane(VerticalScroll):
924
1141
  """
925
1142
  from textual.widgets import Static
926
1143
  self._close_active_assistant()
927
- widget = Static(renderable)
1144
+ # Sanitise the renderable's segment stream — see
1145
+ # ``_SanitizedRenderable`` docstring. This is the only content path
1146
+ # into the ChatPane that doesn't go through ``ChatMessageWidget``,
1147
+ # so it needs its own Layer-7 barrier.
1148
+ widget = Static(_SanitizedRenderable(renderable))
928
1149
  if scrollable:
929
1150
  from textual.containers import VerticalScroll
930
1151
  wrapper = VerticalScroll()
@@ -938,6 +1159,17 @@ class ChatPane(VerticalScroll):
938
1159
  # row bleeding outside the box.
939
1160
  wrapper.styles.padding = 0
940
1161
  wrapper.styles.margin = 0
1162
+ # ``VerticalScroll`` is focusable by default so users can Tab
1163
+ # into it for keyboard scrolling. Inside the chat flow that's
1164
+ # the wrong default — the user navigates via the outer
1165
+ # ``ChatPane`` (whole-conversation scroll) and the wheel works
1166
+ # over an inner panel without focus thanks to Textual's
1167
+ # pointer-based wheel routing. Leaving these focusable makes
1168
+ # them race the ``Input`` for focus during plan/task/diff
1169
+ # mounts, with the symptom that typing stops reaching the
1170
+ # prompt mid-stream. ``can_focus = False`` removes them from
1171
+ # the focus chain entirely. (Layer 11 in the chat.py post-mortem.)
1172
+ wrapper.can_focus = False
941
1173
  self.mount(wrapper)
942
1174
  wrapper.mount(widget)
943
1175
  else: