aru-code 0.42.0__tar.gz → 0.45.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 (186) hide show
  1. {aru_code-0.42.0/aru_code.egg-info → aru_code-0.45.0}/PKG-INFO +1 -1
  2. aru_code-0.45.0/aru/__init__.py +1 -0
  3. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/app.py +231 -14
  4. aru_code-0.45.0/aru/tui/sanitize.py +76 -0
  5. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/choice.py +20 -3
  6. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/confirm.py +4 -1
  7. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/text_input.py +4 -1
  8. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/chat.py +226 -58
  9. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/inline_choice.py +10 -3
  10. {aru_code-0.42.0 → aru_code-0.45.0/aru_code.egg-info}/PKG-INFO +1 -1
  11. {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/SOURCES.txt +2 -0
  12. {aru_code-0.42.0 → aru_code-0.45.0}/pyproject.toml +1 -1
  13. aru_code-0.45.0/tests/test_tui_layer12_recovery.py +120 -0
  14. aru_code-0.42.0/aru/__init__.py +0 -1
  15. {aru_code-0.42.0 → aru_code-0.45.0}/LICENSE +0 -0
  16. {aru_code-0.42.0 → aru_code-0.45.0}/README.md +0 -0
  17. {aru_code-0.42.0 → aru_code-0.45.0}/aru/agent_factory.py +0 -0
  18. {aru_code-0.42.0 → aru_code-0.45.0}/aru/agents/__init__.py +0 -0
  19. {aru_code-0.42.0 → aru_code-0.45.0}/aru/agents/base.py +0 -0
  20. {aru_code-0.42.0 → aru_code-0.45.0}/aru/agents/catalog.py +0 -0
  21. {aru_code-0.42.0 → aru_code-0.45.0}/aru/agents/planner.py +0 -0
  22. {aru_code-0.42.0 → aru_code-0.45.0}/aru/cache_patch.py +0 -0
  23. {aru_code-0.42.0 → aru_code-0.45.0}/aru/checkpoints.py +0 -0
  24. {aru_code-0.42.0 → aru_code-0.45.0}/aru/cli.py +0 -0
  25. {aru_code-0.42.0 → aru_code-0.45.0}/aru/commands.py +0 -0
  26. {aru_code-0.42.0 → aru_code-0.45.0}/aru/completers.py +0 -0
  27. {aru_code-0.42.0 → aru_code-0.45.0}/aru/config.py +0 -0
  28. {aru_code-0.42.0 → aru_code-0.45.0}/aru/context.py +0 -0
  29. {aru_code-0.42.0 → aru_code-0.45.0}/aru/display.py +0 -0
  30. {aru_code-0.42.0 → aru_code-0.45.0}/aru/events.py +0 -0
  31. {aru_code-0.42.0 → aru_code-0.45.0}/aru/format/__init__.py +0 -0
  32. {aru_code-0.42.0 → aru_code-0.45.0}/aru/format/manager.py +0 -0
  33. {aru_code-0.42.0 → aru_code-0.45.0}/aru/format/runner.py +0 -0
  34. {aru_code-0.42.0 → aru_code-0.45.0}/aru/history_blocks.py +0 -0
  35. {aru_code-0.42.0 → aru_code-0.45.0}/aru/lsp/__init__.py +0 -0
  36. {aru_code-0.42.0 → aru_code-0.45.0}/aru/lsp/client.py +0 -0
  37. {aru_code-0.42.0 → aru_code-0.45.0}/aru/lsp/manager.py +0 -0
  38. {aru_code-0.42.0 → aru_code-0.45.0}/aru/lsp/protocol.py +0 -0
  39. {aru_code-0.42.0 → aru_code-0.45.0}/aru/memory/__init__.py +0 -0
  40. {aru_code-0.42.0 → aru_code-0.45.0}/aru/memory/extractor.py +0 -0
  41. {aru_code-0.42.0 → aru_code-0.45.0}/aru/memory/loader.py +0 -0
  42. {aru_code-0.42.0 → aru_code-0.45.0}/aru/memory/store.py +0 -0
  43. {aru_code-0.42.0 → aru_code-0.45.0}/aru/permissions.py +0 -0
  44. {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugin_cache.py +0 -0
  45. {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/__init__.py +0 -0
  46. {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/custom_tools.py +0 -0
  47. {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/hooks.py +0 -0
  48. {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/manager.py +0 -0
  49. {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/tool_api.py +0 -0
  50. {aru_code-0.42.0 → aru_code-0.45.0}/aru/providers.py +0 -0
  51. {aru_code-0.42.0 → aru_code-0.45.0}/aru/runner.py +0 -0
  52. {aru_code-0.42.0 → aru_code-0.45.0}/aru/runtime.py +0 -0
  53. {aru_code-0.42.0 → aru_code-0.45.0}/aru/select.py +0 -0
  54. {aru_code-0.42.0 → aru_code-0.45.0}/aru/session.py +0 -0
  55. {aru_code-0.42.0 → aru_code-0.45.0}/aru/sinks.py +0 -0
  56. {aru_code-0.42.0 → aru_code-0.45.0}/aru/streaming.py +0 -0
  57. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tool_policy.py +0 -0
  58. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/__init__.py +0 -0
  59. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/_diff.py +0 -0
  60. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/_shared.py +0 -0
  61. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/apply_patch.py +0 -0
  62. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/apply_patch_prompt.txt +0 -0
  63. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/ast_tools.py +0 -0
  64. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/codebase.py +0 -0
  65. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/delegate.py +0 -0
  66. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/delegate_prompt.txt +0 -0
  67. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/file_ops.py +0 -0
  68. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/gitignore.py +0 -0
  69. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/lsp.py +0 -0
  70. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/mcp_client.py +0 -0
  71. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/memory_tool.py +0 -0
  72. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/plan_mode.py +0 -0
  73. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/ranker.py +0 -0
  74. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/registry.py +0 -0
  75. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/search.py +0 -0
  76. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/shell.py +0 -0
  77. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/skill.py +0 -0
  78. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/tasklist.py +0 -0
  79. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/web.py +0 -0
  80. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/worktree.py +0 -0
  81. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/__init__.py +0 -0
  82. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/__init__.py +0 -0
  83. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/search.py +0 -0
  84. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/sinks.py +0 -0
  85. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/slash_bridge.py +0 -0
  86. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/ui.py +0 -0
  87. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/__init__.py +0 -0
  88. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/completer.py +0 -0
  89. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/context_pane.py +0 -0
  90. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/header.py +0 -0
  91. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/loaded_pane.py +0 -0
  92. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/status.py +0 -0
  93. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/thinking.py +0 -0
  94. {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/tools.py +0 -0
  95. {aru_code-0.42.0 → aru_code-0.45.0}/aru/ui.py +0 -0
  96. {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/dependency_links.txt +0 -0
  97. {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/entry_points.txt +0 -0
  98. {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/requires.txt +0 -0
  99. {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/top_level.txt +0 -0
  100. {aru_code-0.42.0 → aru_code-0.45.0}/setup.cfg +0 -0
  101. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_agents_base.py +0 -0
  102. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_agents_md_coverage.py +0 -0
  103. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_apply_patch.py +0 -0
  104. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_async_tool_permission.py +0 -0
  105. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cache_patch_metrics.py +0 -0
  106. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cache_patch_stop_reason.py +0 -0
  107. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_catalog.py +0 -0
  108. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_chat_scrollable.py +0 -0
  109. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_checkpoints.py +0 -0
  110. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli.py +0 -0
  111. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_advanced.py +0 -0
  112. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_base.py +0 -0
  113. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_completers.py +0 -0
  114. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_new.py +0 -0
  115. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_run_cli.py +0 -0
  116. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_session.py +0 -0
  117. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_shell.py +0 -0
  118. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_codebase.py +0 -0
  119. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_confabulation_regression.py +0 -0
  120. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_config.py +0 -0
  121. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_context.py +0 -0
  122. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_context_pane.py +0 -0
  123. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cwd_awareness.py +0 -0
  124. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_delegate.py +0 -0
  125. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_events_backward_compat.py +0 -0
  126. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_events_schema.py +0 -0
  127. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_fork_ctx_concurrency.py +0 -0
  128. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_format.py +0 -0
  129. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_gitignore.py +0 -0
  130. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_guardrails_scenarios.py +0 -0
  131. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_invoke_skill.py +0 -0
  132. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_invoked_skills.py +0 -0
  133. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_loaded_pane_path.py +0 -0
  134. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_lsp.py +0 -0
  135. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_lsp_rename.py +0 -0
  136. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_main.py +0 -0
  137. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_markdown_to_text.py +0 -0
  138. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_mcp_client.py +0 -0
  139. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_mcp_health.py +0 -0
  140. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_memory.py +0 -0
  141. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_memory_tool.py +0 -0
  142. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_microcompact.py +0 -0
  143. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_permissions.py +0 -0
  144. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plan_mode_refactor.py +0 -0
  145. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plugin_cache.py +0 -0
  146. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plugin_errors.py +0 -0
  147. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plugin_hooks_v2.py +0 -0
  148. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plugins.py +0 -0
  149. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_providers.py +0 -0
  150. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_ranker.py +0 -0
  151. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_reasoning.py +0 -0
  152. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_runner_interrupt.py +0 -0
  153. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_runner_recovery.py +0 -0
  154. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_runtime.py +0 -0
  155. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_select.py +0 -0
  156. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_skill_disallowed_tools.py +0 -0
  157. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_status_breakdown.py +0 -0
  158. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_status_cost.py +0 -0
  159. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_streaming_sink.py +0 -0
  160. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tasklist.py +0 -0
  161. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_thread_tool_timeout.py +0 -0
  162. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tool_policy.py +0 -0
  163. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_truncation_marker.py +0 -0
  164. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_app_boot.py +0 -0
  165. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_bindings.py +0 -0
  166. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_bus_flow.py +0 -0
  167. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_chat.py +0 -0
  168. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_chat_adversarial.py +0 -0
  169. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_completer.py +0 -0
  170. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_completer_dynamic.py +0 -0
  171. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_copy.py +0 -0
  172. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_input_behaviour.py +0 -0
  173. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_mention_expand.py +0 -0
  174. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_modals.py +0 -0
  175. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_mode_cycle.py +0 -0
  176. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_native_selection.py +0 -0
  177. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_permission_flow.py +0 -0
  178. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_plan_task_render.py +0 -0
  179. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_sidebar_toggle.py +0 -0
  180. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_slash_bridge.py +0 -0
  181. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_snapshot_smoke.py +0 -0
  182. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_thinking_and_boot.py +0 -0
  183. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_widgets_visual.py +0 -0
  184. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_ui_adapter.py +0 -0
  185. {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_worktree.py +0 -0
  186. {aru_code-0.42.0 → aru_code-0.45.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.45.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.45.0"
@@ -32,6 +32,7 @@ from __future__ import annotations
32
32
 
33
33
  import asyncio
34
34
  import sys
35
+ import time
35
36
  from typing import Any
36
37
 
37
38
  from textual.app import App, ComposeResult
@@ -256,6 +257,25 @@ class AruApp(App):
256
257
  "skills", "agents", "commands", "mcp", "yolo",
257
258
  }
258
259
 
260
+ # Layer 10 / 12 — interval (seconds) between belt-and-suspenders re-emits
261
+ # of the mouse-tracking enable sequences. Was 8s pre-Layer-12; user
262
+ # report on 2026-04-25 against ``final-fantasy-9/.aru/sessions/b33dfb99``
263
+ # was that the wheel never came back even after the turn ended in YOLO,
264
+ # and 8s was visibly long enough that the user gave up before the next
265
+ # tick. 3s is short enough that a corrupted state self-heals before the
266
+ # next mouse interaction, and the cost is still ~64 bytes per tick (the
267
+ # Layer 12 off-then-on shake — see ``_reenable_mouse_tracking``).
268
+ _MOUSE_REENABLE_INTERVAL: float = 3.0
269
+
270
+ # Layer 12 — minimum interval (seconds) between keypress-triggered
271
+ # mouse-tracking re-arms. Each keystroke is an opportunity to recover
272
+ # — if the user is typing it might be precisely BECAUSE the wheel just
273
+ # stopped working — but we don't want a fast typist to turn every
274
+ # keystroke into four extra terminal writes. 500 ms is below human
275
+ # noticeable retry latency yet caps the keystroke→write amplification
276
+ # at ~2 Hz worst case.
277
+ _KEYPRESS_REARM_DEBOUNCE: float = 0.5
278
+
259
279
  def __init__(
260
280
  self,
261
281
  *,
@@ -281,6 +301,12 @@ class AruApp(App):
281
301
  # cleared) by on_input_submitted.
282
302
  self._pending_paste: str | None = None
283
303
  self._pending_paste_lines: int = 0
304
+ # Layer 12 — last time we re-emitted the mouse-tracking enable
305
+ # sequences via the keypress path. Used to debounce per-keystroke
306
+ # re-arming so a fast typist doesn't spam the terminal with re-
307
+ # enables. Initialised to negative infinity so the first keystroke
308
+ # always rearms.
309
+ self._last_mouse_reenable_at: float = float("-inf")
284
310
 
285
311
  # ── Composition ──────────────────────────────────────────────────
286
312
 
@@ -375,6 +401,21 @@ class AruApp(App):
375
401
  if not self.is_headless:
376
402
  _push_terminal_title()
377
403
  _set_terminal_title(_compose_terminal_title(self.session))
404
+ # Layer 10 / 11 self-heal — periodic recovery of terminal state and
405
+ # input focus. Two failure classes share one tick:
406
+ # * mouse-enable lost (leaked DEC private-mode escape disabled the
407
+ # wheel) — re-emit ``_enable_mouse_support`` (Layer 10).
408
+ # * input focus / visibility lost (a focusable panel mounted by
409
+ # ``add_renderable`` grabbed focus, or an ``InlineChoicePrompt``
410
+ # left ``#input.-hidden`` stuck because its callback raised) —
411
+ # reassert the prompt as focused-and-visible (Layer 11).
412
+ # Both checks are idempotent on a healthy app and skipped under
413
+ # headless tests where there's no live driver to talk to.
414
+ if not self.is_headless:
415
+ self.set_interval(
416
+ self._MOUSE_REENABLE_INTERVAL,
417
+ self._self_heal_terminal_state,
418
+ )
378
419
 
379
420
  def _replay_resumed_history(self, chat: ChatPane) -> None:
380
421
  """Render a resumed session's user/assistant text back into the chat.
@@ -527,7 +568,14 @@ class AruApp(App):
527
568
  suggestion and fires ``Input.Submitted``, which produced the
528
569
  "three Enters to run /help" glitch. Tab is the only key that
529
570
  accepts the highlighted suggestion.
571
+
572
+ Layer 12 — every keystroke is also a recovery opportunity. The
573
+ Layer 10 periodic tick still runs every ``_MOUSE_REENABLE_INTERVAL``
574
+ but a typing user wants the wheel back NOW, not in three seconds.
575
+ Debounced via ``_KEYPRESS_REARM_DEBOUNCE`` so a fast typist
576
+ doesn't amplify each keystroke into four extra terminal writes.
530
577
  """
578
+ self._maybe_rearm_mouse_on_keypress()
531
579
  try:
532
580
  completer = self.query_one(SlashCompleter)
533
581
  except Exception:
@@ -1069,23 +1117,192 @@ class AruApp(App):
1069
1117
  except Exception:
1070
1118
  pass
1071
1119
  # 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.
1120
+ # sequences at the turn boundary. See ``_reenable_mouse_tracking``
1121
+ # for the rationale; here we eagerly recover the moment the
1122
+ # turn ends so the user's first post-turn scroll always works,
1123
+ # without waiting for the periodic Layer 10 tick.
1124
+ self._reenable_mouse_tracking()
1125
+
1126
+ # Layer 12 DEC private-mode sequences for mouse tracking. Defined
1127
+ # at class scope so both the off-then-on shake below and any future
1128
+ # caller (Click handler, focus event) can reuse the exact same set
1129
+ # without drift.
1130
+ _MOUSE_DISABLE_SEQS: tuple[str, ...] = (
1131
+ "\x1b[?1000l",
1132
+ "\x1b[?1003l",
1133
+ "\x1b[?1015l",
1134
+ "\x1b[?1006l",
1135
+ )
1136
+ _MOUSE_ENABLE_SEQS: tuple[str, ...] = (
1137
+ "\x1b[?1000h",
1138
+ "\x1b[?1003h",
1139
+ "\x1b[?1015h",
1140
+ "\x1b[?1006h",
1141
+ )
1142
+
1143
+ def _reenable_mouse_tracking(self) -> None:
1144
+ """Re-arm mouse tracking via an off-then-on shake (Layer 12).
1145
+
1146
+ Pre-Layer-12 this method delegated to the driver's
1147
+ ``_enable_mouse_support`` which writes four short SGR sequences
1148
+ (``?1000h`` / ``?1003h`` / ``?1015h`` / ``?1006h``). That worked
1149
+ when the terminal forwarded the writes verbatim, but the user
1150
+ report on 2026-04-25 against
1151
+ ``final-fantasy-9/.aru/sessions/b33dfb99`` was the wheel never
1152
+ coming back even though Layer 9 (turn-boundary call) and Layer 10
1153
+ (8s tick) both ran. Two failure modes the old code couldn't
1154
+ recover from:
1155
+
1156
+ 1. **ConPTY enable cache.** Windows ConPTY tracks DEC private-mode
1157
+ state on its side and may treat ``?1000h`` as a no-op when its
1158
+ cache says "already enabled" — even when the underlying
1159
+ terminal lost the state. Sending ``?1000l`` first forces the
1160
+ cache through a state transition so the subsequent ``?1000h``
1161
+ is propagated.
1162
+ 2. **Driver-side gate.** ``WindowsDriver._enable_mouse_support``
1163
+ opens with ``if not self._mouse: return`` (textual 8.2.4,
1164
+ windows_driver.py:55). If a future Textual flips the gate
1165
+ during shutdown / pause / alt-screen toggle, our recovery
1166
+ silently no-ops. Going through ``driver.write`` bypasses the
1167
+ gate and writes the bytes regardless.
1168
+
1169
+ Implementation: emit all four ``...l`` (off) sequences, then all
1170
+ four ``...h`` (on) sequences, then flush. Eight short writes
1171
+ (~64 bytes) bufferised into one terminal emit by ``WriterThread``.
1172
+ Idempotent on a healthy terminal — the off→on cycle leaves the
1173
+ final state identical to a single ``?1000h``, just with a
1174
+ microscopic gap during the transition (no observable wheel-event
1175
+ loss in practice).
1176
+
1177
+ Called from three sites:
1178
+ * ``_run_turn`` finally-clause (Layer 9) — eager recovery at every
1179
+ turn boundary so the first post-turn scroll always works.
1180
+ * ``_self_heal_terminal_state`` periodic tick (Layer 10) — recovers
1181
+ mid-turn corruption within ``_MOUSE_REENABLE_INTERVAL``.
1182
+ * ``on_key`` keypress trigger (Layer 12) — recovers the moment the
1183
+ user touches the keyboard, since a keystroke is a strong signal
1184
+ they noticed the wheel is dead.
1185
+
1186
+ Wrapped in ``try/except`` because the driver may be ``None`` in
1187
+ headless / test mode; we'd rather no-op silently than crash.
1188
+ """
1189
+ try:
1190
+ driver = self._driver
1191
+ if driver is None:
1192
+ return
1193
+ for seq in self._MOUSE_DISABLE_SEQS:
1194
+ try:
1195
+ driver.write(seq)
1196
+ except Exception:
1197
+ pass
1198
+ for seq in self._MOUSE_ENABLE_SEQS:
1199
+ try:
1200
+ driver.write(seq)
1201
+ except Exception:
1202
+ pass
1083
1203
  try:
1084
- driver = self._driver
1085
- if driver is not None:
1086
- driver._enable_mouse_support()
1204
+ driver.flush()
1087
1205
  except Exception:
1088
1206
  pass
1207
+ except Exception:
1208
+ pass
1209
+
1210
+ def _maybe_rearm_mouse_on_keypress(self) -> None:
1211
+ """Layer 12 — re-arm mouse tracking on each keystroke (debounced).
1212
+
1213
+ Trigger fires from ``on_key`` so any user keypress is treated as a
1214
+ recovery opportunity. A typing user is the strongest signal we
1215
+ have that the wheel just stopped working — they reached for the
1216
+ keyboard because the mouse stopped responding, or they're about
1217
+ to scroll back with PgUp and want it ready. Either way, paying
1218
+ ~64 bytes per keypress (capped at 2 Hz by ``_KEYPRESS_REARM_DEBOUNCE``)
1219
+ is a trivial cost for sub-second recovery latency.
1220
+
1221
+ The debounce intentionally uses ``time.monotonic`` rather than the
1222
+ Textual scheduler so it survives across the async ``on_key``
1223
+ boundary without an extra task. ``-inf`` initial value guarantees
1224
+ the first keystroke always rearms.
1225
+ """
1226
+ now = time.monotonic()
1227
+ if now - self._last_mouse_reenable_at < self._KEYPRESS_REARM_DEBOUNCE:
1228
+ return
1229
+ self._last_mouse_reenable_at = now
1230
+ self._reenable_mouse_tracking()
1231
+
1232
+ def _self_heal_terminal_state(self) -> None:
1233
+ """Periodic recovery of mouse tracking and input focus (Layers 10 + 11).
1234
+
1235
+ Two failure classes that the tick recovers from:
1236
+
1237
+ 1. **Terminal mouse-tracking lost.** Layer 9 already re-enables at
1238
+ the turn boundary; this catches mid-turn corruption so the
1239
+ wheel comes back within ``_MOUSE_REENABLE_INTERVAL`` instead
1240
+ of waiting for the agent to finish.
1241
+ 2. **Input prompt invisible or unfocused** when nothing else
1242
+ legitimately owns it. Three concrete scenarios this fixes:
1243
+ * an ``InlineChoicePrompt`` callback raised before
1244
+ ``on_unmount`` ran, leaving ``#input.-hidden`` stuck;
1245
+ * a focusable panel mounted by ``add_renderable`` (pre-Layer-11
1246
+ behaviour) grabbed focus and never released it;
1247
+ * an exception during ``finalize_assistant_message`` cancelled
1248
+ a focus-restore that ``_run_turn`` would normally do.
1249
+
1250
+ We only intervene when **no modal is on top** (modal owns input,
1251
+ ``len(self.screen_stack) <= 1``) and **no ``InlineChoicePrompt``
1252
+ is currently mounted** (the inline prompt legitimately steals
1253
+ focus and hides the input by design — touching it mid-flight
1254
+ would steal back from the user). When both conditions hold, we
1255
+ treat the input as the canonical focus target.
1256
+ """
1257
+ # Layer 10 — mouse tracking.
1258
+ self._reenable_mouse_tracking()
1259
+
1260
+ # Layer 11 — input watchdog. Skip if a modal is on top: the modal
1261
+ # is the legitimate input owner and the underlying ``Input`` is
1262
+ # not part of the active focus chain.
1263
+ try:
1264
+ if len(self.screen_stack) > 1:
1265
+ return
1266
+ except Exception:
1267
+ return
1268
+
1269
+ # Skip if an ``InlineChoicePrompt`` is currently mounted: it has
1270
+ # explicitly hidden the input and owns the focus while waiting
1271
+ # for the user's choice. ``query`` returns an empty list when the
1272
+ # widget tree has no match, so the truth-test is safe.
1273
+ try:
1274
+ from aru.tui.widgets.inline_choice import InlineChoicePrompt
1275
+ if list(self.query(InlineChoicePrompt)):
1276
+ return
1277
+ except Exception:
1278
+ pass
1279
+
1280
+ # Recover ``#input`` if it's stuck hidden (the ``-hidden`` class
1281
+ # comes off only inside ``InlineChoicePrompt._toggle_input``; if
1282
+ # that didn't run because the callback raised, the user is
1283
+ # stranded with no visible prompt). ``remove_class`` on a class
1284
+ # that isn't applied is a no-op, so the unconditional call is safe.
1285
+ try:
1286
+ inp = self.query_one(Input)
1287
+ except Exception:
1288
+ return
1289
+ try:
1290
+ if inp.has_class("-hidden"):
1291
+ inp.remove_class("-hidden")
1292
+ except Exception:
1293
+ pass
1294
+
1295
+ # Re-focus only when *nothing* currently has focus. We deliberately
1296
+ # do NOT yank focus away from a sidebar / scrollback / search
1297
+ # screen the user navigated to themselves — that would fight
1298
+ # legitimate keyboard navigation. The ``focused is None`` guard
1299
+ # narrows the recovery to the ghost-focus state we actually
1300
+ # observed in the bug.
1301
+ try:
1302
+ if self.screen.focused is None:
1303
+ inp.focus()
1304
+ except Exception:
1305
+ pass
1089
1306
 
1090
1307
  # ── Bus wiring — ToolsPane + StatusPane subscribe to plugin events ──
1091
1308
 
@@ -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,