aru-code 0.42.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.42.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.42.0 → aru_code-0.44.0}/aru/tui/app.py +130 -17
  4. aru_code-0.44.0/aru/tui/sanitize.py +76 -0
  5. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/choice.py +20 -3
  6. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/confirm.py +4 -1
  7. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/text_input.py +4 -1
  8. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/chat.py +148 -58
  9. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/inline_choice.py +10 -3
  10. {aru_code-0.42.0 → aru_code-0.44.0/aru_code.egg-info}/PKG-INFO +1 -1
  11. {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/SOURCES.txt +1 -0
  12. {aru_code-0.42.0 → aru_code-0.44.0}/pyproject.toml +1 -1
  13. aru_code-0.42.0/aru/__init__.py +0 -1
  14. {aru_code-0.42.0 → aru_code-0.44.0}/LICENSE +0 -0
  15. {aru_code-0.42.0 → aru_code-0.44.0}/README.md +0 -0
  16. {aru_code-0.42.0 → aru_code-0.44.0}/aru/agent_factory.py +0 -0
  17. {aru_code-0.42.0 → aru_code-0.44.0}/aru/agents/__init__.py +0 -0
  18. {aru_code-0.42.0 → aru_code-0.44.0}/aru/agents/base.py +0 -0
  19. {aru_code-0.42.0 → aru_code-0.44.0}/aru/agents/catalog.py +0 -0
  20. {aru_code-0.42.0 → aru_code-0.44.0}/aru/agents/planner.py +0 -0
  21. {aru_code-0.42.0 → aru_code-0.44.0}/aru/cache_patch.py +0 -0
  22. {aru_code-0.42.0 → aru_code-0.44.0}/aru/checkpoints.py +0 -0
  23. {aru_code-0.42.0 → aru_code-0.44.0}/aru/cli.py +0 -0
  24. {aru_code-0.42.0 → aru_code-0.44.0}/aru/commands.py +0 -0
  25. {aru_code-0.42.0 → aru_code-0.44.0}/aru/completers.py +0 -0
  26. {aru_code-0.42.0 → aru_code-0.44.0}/aru/config.py +0 -0
  27. {aru_code-0.42.0 → aru_code-0.44.0}/aru/context.py +0 -0
  28. {aru_code-0.42.0 → aru_code-0.44.0}/aru/display.py +0 -0
  29. {aru_code-0.42.0 → aru_code-0.44.0}/aru/events.py +0 -0
  30. {aru_code-0.42.0 → aru_code-0.44.0}/aru/format/__init__.py +0 -0
  31. {aru_code-0.42.0 → aru_code-0.44.0}/aru/format/manager.py +0 -0
  32. {aru_code-0.42.0 → aru_code-0.44.0}/aru/format/runner.py +0 -0
  33. {aru_code-0.42.0 → aru_code-0.44.0}/aru/history_blocks.py +0 -0
  34. {aru_code-0.42.0 → aru_code-0.44.0}/aru/lsp/__init__.py +0 -0
  35. {aru_code-0.42.0 → aru_code-0.44.0}/aru/lsp/client.py +0 -0
  36. {aru_code-0.42.0 → aru_code-0.44.0}/aru/lsp/manager.py +0 -0
  37. {aru_code-0.42.0 → aru_code-0.44.0}/aru/lsp/protocol.py +0 -0
  38. {aru_code-0.42.0 → aru_code-0.44.0}/aru/memory/__init__.py +0 -0
  39. {aru_code-0.42.0 → aru_code-0.44.0}/aru/memory/extractor.py +0 -0
  40. {aru_code-0.42.0 → aru_code-0.44.0}/aru/memory/loader.py +0 -0
  41. {aru_code-0.42.0 → aru_code-0.44.0}/aru/memory/store.py +0 -0
  42. {aru_code-0.42.0 → aru_code-0.44.0}/aru/permissions.py +0 -0
  43. {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugin_cache.py +0 -0
  44. {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/__init__.py +0 -0
  45. {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/custom_tools.py +0 -0
  46. {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/hooks.py +0 -0
  47. {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/manager.py +0 -0
  48. {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/tool_api.py +0 -0
  49. {aru_code-0.42.0 → aru_code-0.44.0}/aru/providers.py +0 -0
  50. {aru_code-0.42.0 → aru_code-0.44.0}/aru/runner.py +0 -0
  51. {aru_code-0.42.0 → aru_code-0.44.0}/aru/runtime.py +0 -0
  52. {aru_code-0.42.0 → aru_code-0.44.0}/aru/select.py +0 -0
  53. {aru_code-0.42.0 → aru_code-0.44.0}/aru/session.py +0 -0
  54. {aru_code-0.42.0 → aru_code-0.44.0}/aru/sinks.py +0 -0
  55. {aru_code-0.42.0 → aru_code-0.44.0}/aru/streaming.py +0 -0
  56. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tool_policy.py +0 -0
  57. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/__init__.py +0 -0
  58. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/_diff.py +0 -0
  59. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/_shared.py +0 -0
  60. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/apply_patch.py +0 -0
  61. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/apply_patch_prompt.txt +0 -0
  62. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/ast_tools.py +0 -0
  63. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/codebase.py +0 -0
  64. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/delegate.py +0 -0
  65. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/delegate_prompt.txt +0 -0
  66. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/file_ops.py +0 -0
  67. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/gitignore.py +0 -0
  68. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/lsp.py +0 -0
  69. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/mcp_client.py +0 -0
  70. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/memory_tool.py +0 -0
  71. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/plan_mode.py +0 -0
  72. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/ranker.py +0 -0
  73. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/registry.py +0 -0
  74. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/search.py +0 -0
  75. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/shell.py +0 -0
  76. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/skill.py +0 -0
  77. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/tasklist.py +0 -0
  78. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/web.py +0 -0
  79. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/worktree.py +0 -0
  80. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/__init__.py +0 -0
  81. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/__init__.py +0 -0
  82. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/search.py +0 -0
  83. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/sinks.py +0 -0
  84. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/slash_bridge.py +0 -0
  85. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/ui.py +0 -0
  86. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/__init__.py +0 -0
  87. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/completer.py +0 -0
  88. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/context_pane.py +0 -0
  89. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/header.py +0 -0
  90. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/loaded_pane.py +0 -0
  91. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/status.py +0 -0
  92. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/thinking.py +0 -0
  93. {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/tools.py +0 -0
  94. {aru_code-0.42.0 → aru_code-0.44.0}/aru/ui.py +0 -0
  95. {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/dependency_links.txt +0 -0
  96. {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/entry_points.txt +0 -0
  97. {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/requires.txt +0 -0
  98. {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/top_level.txt +0 -0
  99. {aru_code-0.42.0 → aru_code-0.44.0}/setup.cfg +0 -0
  100. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_agents_base.py +0 -0
  101. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_agents_md_coverage.py +0 -0
  102. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_apply_patch.py +0 -0
  103. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_async_tool_permission.py +0 -0
  104. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cache_patch_metrics.py +0 -0
  105. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cache_patch_stop_reason.py +0 -0
  106. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_catalog.py +0 -0
  107. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_chat_scrollable.py +0 -0
  108. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_checkpoints.py +0 -0
  109. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli.py +0 -0
  110. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_advanced.py +0 -0
  111. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_base.py +0 -0
  112. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_completers.py +0 -0
  113. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_new.py +0 -0
  114. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_run_cli.py +0 -0
  115. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_session.py +0 -0
  116. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_shell.py +0 -0
  117. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_codebase.py +0 -0
  118. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_confabulation_regression.py +0 -0
  119. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_config.py +0 -0
  120. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_context.py +0 -0
  121. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_context_pane.py +0 -0
  122. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cwd_awareness.py +0 -0
  123. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_delegate.py +0 -0
  124. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_events_backward_compat.py +0 -0
  125. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_events_schema.py +0 -0
  126. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_fork_ctx_concurrency.py +0 -0
  127. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_format.py +0 -0
  128. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_gitignore.py +0 -0
  129. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_guardrails_scenarios.py +0 -0
  130. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_invoke_skill.py +0 -0
  131. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_invoked_skills.py +0 -0
  132. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_loaded_pane_path.py +0 -0
  133. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_lsp.py +0 -0
  134. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_lsp_rename.py +0 -0
  135. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_main.py +0 -0
  136. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_markdown_to_text.py +0 -0
  137. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_mcp_client.py +0 -0
  138. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_mcp_health.py +0 -0
  139. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_memory.py +0 -0
  140. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_memory_tool.py +0 -0
  141. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_microcompact.py +0 -0
  142. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_permissions.py +0 -0
  143. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plan_mode_refactor.py +0 -0
  144. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plugin_cache.py +0 -0
  145. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plugin_errors.py +0 -0
  146. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plugin_hooks_v2.py +0 -0
  147. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plugins.py +0 -0
  148. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_providers.py +0 -0
  149. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_ranker.py +0 -0
  150. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_reasoning.py +0 -0
  151. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_runner_interrupt.py +0 -0
  152. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_runner_recovery.py +0 -0
  153. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_runtime.py +0 -0
  154. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_select.py +0 -0
  155. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_skill_disallowed_tools.py +0 -0
  156. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_status_breakdown.py +0 -0
  157. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_status_cost.py +0 -0
  158. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_streaming_sink.py +0 -0
  159. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tasklist.py +0 -0
  160. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_thread_tool_timeout.py +0 -0
  161. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tool_policy.py +0 -0
  162. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_truncation_marker.py +0 -0
  163. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_app_boot.py +0 -0
  164. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_bindings.py +0 -0
  165. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_bus_flow.py +0 -0
  166. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_chat.py +0 -0
  167. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_chat_adversarial.py +0 -0
  168. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_completer.py +0 -0
  169. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_completer_dynamic.py +0 -0
  170. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_copy.py +0 -0
  171. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_input_behaviour.py +0 -0
  172. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_mention_expand.py +0 -0
  173. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_modals.py +0 -0
  174. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_mode_cycle.py +0 -0
  175. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_native_selection.py +0 -0
  176. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_permission_flow.py +0 -0
  177. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_plan_task_render.py +0 -0
  178. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_sidebar_toggle.py +0 -0
  179. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_slash_bridge.py +0 -0
  180. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_snapshot_smoke.py +0 -0
  181. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_thinking_and_boot.py +0 -0
  182. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_widgets_visual.py +0 -0
  183. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_ui_adapter.py +0 -0
  184. {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_worktree.py +0 -0
  185. {aru_code-0.42.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.42.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.
@@ -1069,23 +1091,114 @@ class AruApp(App):
1069
1091
  except Exception:
1070
1092
  pass
1071
1093
  # Layer 9 self-heal — re-assert Textual's mouse-tracking
1072
- # sequences at every turn boundary. If any content path
1073
- # emitted a rogue DEC private-mode escape during the turn
1074
- # (``\x1b[?1000l`` or similar) the terminal would have
1075
- # silently disabled wheel reporting for every scroll area
1076
- # in the app, with no way for us to detect it. Calling the
1077
- # driver's enable-mouse path writes four short SGR sequences
1078
- # (``?1000h`` / ``?1003h`` / ``?1015h`` / ``?1006h``) and
1079
- # restores wheel input no matter what corrupted the state.
1080
- # Idempotent when mouse tracking was never disabled. See the
1081
- # Layer 9 post-mortem at the top of
1082
- # ``aru/tui/widgets/chat.py`` for the full analysis.
1083
- try:
1084
- driver = self._driver
1085
- if driver is not None:
1086
- driver._enable_mouse_support()
1087
- except Exception:
1088
- pass
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
1089
1202
 
1090
1203
  # ── Bus wiring — ToolsPane + StatusPane subscribe to plugin events ──
1091
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,
@@ -213,6 +213,138 @@ Treat as a ninth layer: defence-in-depth against terminal-state
213
213
  corruption. Prong 1 plugs the last known-possible leak inside our
214
214
  code; prong 2 recovers even if something outside our reach drops
215
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.
216
348
  """
217
349
 
218
350
  from __future__ import annotations
@@ -233,6 +365,11 @@ from textual.containers import VerticalScroll
233
365
  from textual.reactive import reactive
234
366
  from textual.widgets import Static
235
367
 
368
+ from aru.tui.sanitize import (
369
+ SanitizedRenderable as _SanitizedRenderable,
370
+ sanitize_for_terminal as _sanitize_for_terminal,
371
+ )
372
+
236
373
 
237
374
  # Reference-definition line: ``[label]: href`` (optional leading 0–3 spaces).
238
375
  # Presence of *any* reference definition anywhere in the snapshot disables the
@@ -242,64 +379,6 @@ from textual.widgets import Static
242
379
  _REF_DEF_RE = re.compile(r"^[ ]{0,3}\[[^\]\n]+\]:\s", re.MULTILINE)
243
380
 
244
381
 
245
- # Strip ASCII control characters (0x00–0x1F plus DEL 0x7F) from any content
246
- # about to be rendered to the terminal, EXCEPT ``\n`` and ``\t`` which are
247
- # semantically meaningful to markdown-it, Rich, and Textual. Rich's ``Text``
248
- # passes escape bytes through verbatim, so if a streamed model reply or a
249
- # tool label contains ``\x1b[?1000l`` (or any other DEC private-mode escape),
250
- # the terminal receives it directly and globally disables mouse tracking —
251
- # at which point every scroll area in the TUI stops responding to the
252
- # mouse wheel (keyboard still works, which is why the bug presents as
253
- # "only scroll froze"). Models that talk about terminal control sequences
254
- # or tools that echo subprocess output are the realistic injection paths.
255
- _CTRL_CHAR_TRANSLATION = {c: None for c in range(32) if chr(c) not in ("\n", "\t")}
256
- _CTRL_CHAR_TRANSLATION[0x7F] = None
257
-
258
-
259
- def _sanitize_for_terminal(raw: str) -> str:
260
- """Remove non-printable control chars so rogue ANSI escapes can't reach the tty.
261
-
262
- Keeps ``\\n`` and ``\\t``; drops everything else in the C0 range plus DEL.
263
- Cheap: ``str.translate`` is implemented in C and runs in microseconds for
264
- multi-KB inputs. Applied at every boundary where chat content becomes a
265
- Rich renderable.
266
- """
267
- return raw.translate(_CTRL_CHAR_TRANSLATION)
268
-
269
-
270
- class _SanitizedRenderable:
271
- """Wraps a Rich renderable so its output segments are stripped of C0 bytes.
272
-
273
- ``_sanitize_for_terminal`` covers every string passing through
274
- ``ChatMessageWidget.buffer``. Arbitrary Rich renderables handed to
275
- ``ChatPane.add_renderable`` (plan panels, task lists, diff previews, the
276
- startup logo) skip that widget and mount as a plain ``Static(renderable)``
277
- — so a rogue ``\\x1b[?1000l`` inside a task description, a panel title, or
278
- subprocess output echoed into a panel would flow straight through Rich
279
- segments to Textual's compositor and onto the terminal, disabling mouse
280
- tracking globally (Layer 7 signature).
281
-
282
- This wrapper closes that gap: ``console.render`` yields segments from the
283
- inner renderable, we strip C0 bytes from any segment whose ``.text``
284
- contains them, and re-emit the cleaned stream. Rich's ``Segment`` is a
285
- ``NamedTuple`` so ``seg._replace(text=...)`` is a cheap immutable swap.
286
- Unchanged segments are re-emitted unmodified — the hot path is a single
287
- ``str.translate`` on segment text which typically no-ops.
288
- """
289
-
290
- def __init__(self, inner: Any) -> None:
291
- self._inner = inner
292
-
293
- def __rich_console__(self, console: Any, options: Any) -> Any:
294
- for seg in console.render(self._inner, options):
295
- if seg.text:
296
- clean = _sanitize_for_terminal(seg.text)
297
- if clean != seg.text:
298
- yield seg._replace(text=clean)
299
- continue
300
- yield seg
301
-
302
-
303
382
  def _scan_fences(text: str) -> tuple[int, int]:
304
383
  """One-pass fence scanner. Returns ``(last_stable_split, open_fence_start)``.
305
384
 
@@ -1080,6 +1159,17 @@ class ChatPane(VerticalScroll):
1080
1159
  # row bleeding outside the box.
1081
1160
  wrapper.styles.padding = 0
1082
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
1083
1173
  self.mount(wrapper)
1084
1174
  wrapper.mount(widget)
1085
1175
  else:
@@ -27,6 +27,8 @@ from textual.widget import Widget
27
27
  from textual.widgets import Label, OptionList
28
28
  from textual.widgets.option_list import Option
29
29
 
30
+ from aru.tui.sanitize import sanitize_for_terminal
31
+
30
32
 
31
33
  class InlineChoicePrompt(Widget):
32
34
  """Approval prompt rendered as a bordered widget in the ChatPane flow."""
@@ -82,13 +84,18 @@ class InlineChoicePrompt(Widget):
82
84
  self._fired = False
83
85
 
84
86
  def compose(self) -> ComposeResult:
87
+ # Sanitise every caller-supplied string — title, hint, and option
88
+ # labels can carry text from the agent, a tool result, or a plan
89
+ # summary, all of which may contain raw C0 escapes that would
90
+ # disable mouse tracking globally if they reached the terminal.
91
+ # Same boundary as ``ChoiceModal``; see Layer 10 in chat.py.
85
92
  if self._title:
86
- yield Label(self._title, classes="title")
93
+ yield Label(sanitize_for_terminal(self._title), classes="title")
87
94
  if self._hint:
88
- yield Label(self._hint, classes="hint")
95
+ yield Label(sanitize_for_terminal(self._hint), classes="hint")
89
96
  yield OptionList(
90
97
  *[
91
- Option(label, id=str(i))
98
+ Option(sanitize_for_terminal(label), id=str(i))
92
99
  for i, label in enumerate(self._options)
93
100
  ],
94
101
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.42.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
@@ -69,6 +69,7 @@ aru/tools/web.py
69
69
  aru/tools/worktree.py
70
70
  aru/tui/__init__.py
71
71
  aru/tui/app.py
72
+ aru/tui/sanitize.py
72
73
  aru/tui/sinks.py
73
74
  aru/tui/slash_bridge.py
74
75
  aru/tui/ui.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.42.0"
7
+ version = "0.44.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1 +0,0 @@
1
- __version__ = "0.42.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes