aru-code 0.54.0__tar.gz → 0.55.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 (213) hide show
  1. {aru_code-0.54.0/aru_code.egg-info → aru_code-0.55.0}/PKG-INFO +1 -1
  2. aru_code-0.55.0/aru/__init__.py +1 -0
  3. {aru_code-0.54.0 → aru_code-0.55.0}/aru/permissions.py +27 -3
  4. {aru_code-0.54.0 → aru_code-0.55.0}/aru/runtime.py +95 -0
  5. aru_code-0.55.0/aru/tools/_shared.py +145 -0
  6. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/apply_patch.py +83 -0
  7. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/lsp.py +29 -0
  8. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/registry.py +10 -1
  9. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/ui.py +65 -23
  10. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/thinking.py +31 -4
  11. {aru_code-0.54.0 → aru_code-0.55.0/aru_code.egg-info}/PKG-INFO +1 -1
  12. {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/SOURCES.txt +1 -0
  13. {aru_code-0.54.0 → aru_code-0.55.0}/pyproject.toml +1 -1
  14. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_apply_patch.py +133 -0
  15. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_lsp_rename.py +54 -0
  16. aru_code-0.55.0/tests/test_permission_timeout_suspension.py +171 -0
  17. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_permission_flow.py +59 -0
  18. aru_code-0.54.0/aru/__init__.py +0 -1
  19. aru_code-0.54.0/aru/tools/_shared.py +0 -94
  20. {aru_code-0.54.0 → aru_code-0.55.0}/LICENSE +0 -0
  21. {aru_code-0.54.0 → aru_code-0.55.0}/README.md +0 -0
  22. {aru_code-0.54.0 → aru_code-0.55.0}/aru/_debug/__init__.py +0 -0
  23. {aru_code-0.54.0 → aru_code-0.55.0}/aru/_debug/analyze_trace.py +0 -0
  24. {aru_code-0.54.0 → aru_code-0.55.0}/aru/_debug/loop_tracer.py +0 -0
  25. {aru_code-0.54.0 → aru_code-0.55.0}/aru/agent_factory.py +0 -0
  26. {aru_code-0.54.0 → aru_code-0.55.0}/aru/agents/__init__.py +0 -0
  27. {aru_code-0.54.0 → aru_code-0.55.0}/aru/agents/base.py +0 -0
  28. {aru_code-0.54.0 → aru_code-0.55.0}/aru/agents/catalog.py +0 -0
  29. {aru_code-0.54.0 → aru_code-0.55.0}/aru/agents/planner.py +0 -0
  30. {aru_code-0.54.0 → aru_code-0.55.0}/aru/cache_patch.py +0 -0
  31. {aru_code-0.54.0 → aru_code-0.55.0}/aru/checkpoints.py +0 -0
  32. {aru_code-0.54.0 → aru_code-0.55.0}/aru/cli.py +0 -0
  33. {aru_code-0.54.0 → aru_code-0.55.0}/aru/commands.py +0 -0
  34. {aru_code-0.54.0 → aru_code-0.55.0}/aru/completers.py +0 -0
  35. {aru_code-0.54.0 → aru_code-0.55.0}/aru/config.py +0 -0
  36. {aru_code-0.54.0 → aru_code-0.55.0}/aru/context.py +0 -0
  37. {aru_code-0.54.0 → aru_code-0.55.0}/aru/display.py +0 -0
  38. {aru_code-0.54.0 → aru_code-0.55.0}/aru/doom_loop.py +0 -0
  39. {aru_code-0.54.0 → aru_code-0.55.0}/aru/events.py +0 -0
  40. {aru_code-0.54.0 → aru_code-0.55.0}/aru/format/__init__.py +0 -0
  41. {aru_code-0.54.0 → aru_code-0.55.0}/aru/format/manager.py +0 -0
  42. {aru_code-0.54.0 → aru_code-0.55.0}/aru/format/runner.py +0 -0
  43. {aru_code-0.54.0 → aru_code-0.55.0}/aru/history_blocks.py +0 -0
  44. {aru_code-0.54.0 → aru_code-0.55.0}/aru/lsp/__init__.py +0 -0
  45. {aru_code-0.54.0 → aru_code-0.55.0}/aru/lsp/client.py +0 -0
  46. {aru_code-0.54.0 → aru_code-0.55.0}/aru/lsp/manager.py +0 -0
  47. {aru_code-0.54.0 → aru_code-0.55.0}/aru/lsp/protocol.py +0 -0
  48. {aru_code-0.54.0 → aru_code-0.55.0}/aru/memory/__init__.py +0 -0
  49. {aru_code-0.54.0 → aru_code-0.55.0}/aru/memory/extractor.py +0 -0
  50. {aru_code-0.54.0 → aru_code-0.55.0}/aru/memory/loader.py +0 -0
  51. {aru_code-0.54.0 → aru_code-0.55.0}/aru/memory/store.py +0 -0
  52. {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugin_cache.py +0 -0
  53. {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/__init__.py +0 -0
  54. {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/custom_tools.py +0 -0
  55. {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/hooks.py +0 -0
  56. {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/manager.py +0 -0
  57. {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/tool_api.py +0 -0
  58. {aru_code-0.54.0 → aru_code-0.55.0}/aru/providers.py +0 -0
  59. {aru_code-0.54.0 → aru_code-0.55.0}/aru/runner.py +0 -0
  60. {aru_code-0.54.0 → aru_code-0.55.0}/aru/select.py +0 -0
  61. {aru_code-0.54.0 → aru_code-0.55.0}/aru/session.py +0 -0
  62. {aru_code-0.54.0 → aru_code-0.55.0}/aru/sinks.py +0 -0
  63. {aru_code-0.54.0 → aru_code-0.55.0}/aru/streaming.py +0 -0
  64. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tool_policy.py +0 -0
  65. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/__init__.py +0 -0
  66. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/_diff.py +0 -0
  67. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/apply_patch_prompt.txt +0 -0
  68. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/ast_tools.py +0 -0
  69. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/codebase.py +0 -0
  70. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/delegate.py +0 -0
  71. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/delegate_prompt.txt +0 -0
  72. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/file_ops.py +0 -0
  73. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/gitignore.py +0 -0
  74. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/mcp_client.py +0 -0
  75. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/memory_tool.py +0 -0
  76. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/plan_mode.py +0 -0
  77. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/ranker.py +0 -0
  78. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/search.py +0 -0
  79. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/shell.py +0 -0
  80. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/skill.py +0 -0
  81. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/tasklist.py +0 -0
  82. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/web.py +0 -0
  83. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/worktree.py +0 -0
  84. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/__init__.py +0 -0
  85. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/app.py +0 -0
  86. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/log_bridge.py +0 -0
  87. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/notifications.py +0 -0
  88. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/sanitize.py +0 -0
  89. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/__init__.py +0 -0
  90. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/choice.py +0 -0
  91. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/confirm.py +0 -0
  92. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/keymap.py +0 -0
  93. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/search.py +0 -0
  94. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/session_picker.py +0 -0
  95. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/text_input.py +0 -0
  96. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/sinks.py +0 -0
  97. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/slash_bridge.py +0 -0
  98. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/themes.py +0 -0
  99. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/__init__.py +0 -0
  100. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/chat.py +0 -0
  101. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/completer.py +0 -0
  102. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/context_pane.py +0 -0
  103. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/file_link.py +0 -0
  104. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/header.py +0 -0
  105. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/inline_choice.py +0 -0
  106. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/loaded_pane.py +0 -0
  107. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/prompt_area.py +0 -0
  108. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/prompt_queue.py +0 -0
  109. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/status.py +0 -0
  110. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/subagent_panel.py +0 -0
  111. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/tasklist_panel.py +0 -0
  112. {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/tools.py +0 -0
  113. {aru_code-0.54.0 → aru_code-0.55.0}/aru/ui.py +0 -0
  114. {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/dependency_links.txt +0 -0
  115. {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/entry_points.txt +0 -0
  116. {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/requires.txt +0 -0
  117. {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/top_level.txt +0 -0
  118. {aru_code-0.54.0 → aru_code-0.55.0}/setup.cfg +0 -0
  119. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_agents_base.py +0 -0
  120. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_agents_md_coverage.py +0 -0
  121. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_async_tool_permission.py +0 -0
  122. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cache_patch_metrics.py +0 -0
  123. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cache_patch_stop_reason.py +0 -0
  124. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_catalog.py +0 -0
  125. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_chat_scrollable.py +0 -0
  126. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_checkpoints.py +0 -0
  127. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli.py +0 -0
  128. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_advanced.py +0 -0
  129. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_base.py +0 -0
  130. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_completers.py +0 -0
  131. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_new.py +0 -0
  132. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_run_cli.py +0 -0
  133. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_session.py +0 -0
  134. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_shell.py +0 -0
  135. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_codebase.py +0 -0
  136. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_confabulation_regression.py +0 -0
  137. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_config.py +0 -0
  138. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_context.py +0 -0
  139. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_context_pane.py +0 -0
  140. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cwd_awareness.py +0 -0
  141. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_delegate.py +0 -0
  142. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_doom_loop.py +0 -0
  143. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_events_backward_compat.py +0 -0
  144. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_events_schema.py +0 -0
  145. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_fork_ctx_concurrency.py +0 -0
  146. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_format.py +0 -0
  147. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_gitignore.py +0 -0
  148. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_guardrails_scenarios.py +0 -0
  149. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_invoke_skill.py +0 -0
  150. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_invoked_skills.py +0 -0
  151. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_loaded_pane_path.py +0 -0
  152. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_lsp.py +0 -0
  153. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_main.py +0 -0
  154. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_markdown_to_text.py +0 -0
  155. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_mcp_client.py +0 -0
  156. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_mcp_health.py +0 -0
  157. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_memory.py +0 -0
  158. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_memory_tool.py +0 -0
  159. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_microcompact.py +0 -0
  160. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_permissions.py +0 -0
  161. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plan_mode_refactor.py +0 -0
  162. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plugin_cache.py +0 -0
  163. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plugin_errors.py +0 -0
  164. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plugin_hooks_v2.py +0 -0
  165. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plugins.py +0 -0
  166. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_providers.py +0 -0
  167. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_ranker.py +0 -0
  168. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_reasoning.py +0 -0
  169. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_runner_interrupt.py +0 -0
  170. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_runner_recovery.py +0 -0
  171. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_runtime.py +0 -0
  172. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_select.py +0 -0
  173. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_session_free_cost.py +0 -0
  174. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_skill_disallowed_tools.py +0 -0
  175. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_status_breakdown.py +0 -0
  176. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_status_cost.py +0 -0
  177. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_streaming_sink.py +0 -0
  178. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_subagent_tool_events.py +0 -0
  179. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tasklist.py +0 -0
  180. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_thread_tool_timeout.py +0 -0
  181. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tool_policy.py +0 -0
  182. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_truncation_marker.py +0 -0
  183. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_app_boot.py +0 -0
  184. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_bindings.py +0 -0
  185. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_bus_flow.py +0 -0
  186. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_chat.py +0 -0
  187. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_chat_adversarial.py +0 -0
  188. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_completer.py +0 -0
  189. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_completer_dynamic.py +0 -0
  190. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_copy.py +0 -0
  191. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_error_display.py +0 -0
  192. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_file_link.py +0 -0
  193. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_input_behaviour.py +0 -0
  194. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_layer12_recovery.py +0 -0
  195. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_layer13_recovery.py +0 -0
  196. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_mention_expand.py +0 -0
  197. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_modals.py +0 -0
  198. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_mode_cycle.py +0 -0
  199. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_native_selection.py +0 -0
  200. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_plan_task_render.py +0 -0
  201. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_prompt_queue.py +0 -0
  202. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_shell_bang.py +0 -0
  203. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_sidebar_toggle.py +0 -0
  204. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_slash_bridge.py +0 -0
  205. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_slash_model.py +0 -0
  206. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_snapshot_smoke.py +0 -0
  207. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_subagent_panel.py +0 -0
  208. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_theme.py +0 -0
  209. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_thinking_and_boot.py +0 -0
  210. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_widgets_visual.py +0 -0
  211. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_ui_adapter.py +0 -0
  212. {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_worktree.py +0 -0
  213. {aru_code-0.54.0 → aru_code-0.55.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.54.0
3
+ Version: 0.55.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.55.0"
@@ -27,10 +27,31 @@ from rich.console import Group
27
27
  from rich.panel import Panel
28
28
  from rich.text import Text
29
29
 
30
- from aru.runtime import get_ctx
30
+ from aru.runtime import begin_permission_wait, end_permission_wait, get_ctx
31
31
  from aru.select import select_option
32
32
 
33
33
 
34
+ @contextmanager
35
+ def _permission_prompt_scope(ctx):
36
+ """Hold ``permission_lock`` while marking the tool-call permission gate.
37
+
38
+ The gate tells the surrounding ``_thread_tool`` wrapper to suspend its
39
+ execution-timeout for as long as we are blocked here — acquiring the lock
40
+ can wait on a sibling tool's open prompt, and the prompt itself waits on
41
+ the user. Without this, the tool could report a timeout mid-prompt and
42
+ then apply the mutation out-of-band once the user finally answered.
43
+ ``begin_permission_wait`` runs BEFORE the lock is acquired so the
44
+ lock-wait is covered too; it is a no-op when no gate is installed (async
45
+ tools, tests). See ``aru.runtime.PermissionWaitGate``.
46
+ """
47
+ begin_permission_wait()
48
+ try:
49
+ with ctx.permission_lock:
50
+ yield
51
+ finally:
52
+ end_permission_wait()
53
+
54
+
34
55
  def _resolve_ui(ctx):
35
56
  """Return ``ctx.ui`` or install a ``ReplUI`` on-the-fly.
36
57
 
@@ -891,8 +912,11 @@ def check_permission(
891
912
  except Exception:
892
913
  pass # never let plugin errors block permissions
893
914
 
894
- # action == "ask" -> prompt user
895
- with ctx.permission_lock:
915
+ # action == "ask" -> prompt user. The scope holds permission_lock AND
916
+ # suspends the tool-execution timeout while we block on the user (see
917
+ # _permission_prompt_scope) — so a slow human decision can never let the
918
+ # tool time out and then apply the mutation out-of-band.
919
+ with _permission_prompt_scope(ctx):
896
920
  # Re-check after acquiring lock (another thread may have resolved it)
897
921
  results2 = _resolve_many(category, subjects)
898
922
  if any(action == "deny" for action, _ in results2):
@@ -345,6 +345,101 @@ def is_aborted() -> bool:
345
345
  return False
346
346
 
347
347
 
348
+ # ── Permission-wait gate (tool-timeout suspension) ───────────────────
349
+ #
350
+ # Safety-critical. A tool's execution timeout (see
351
+ # ``aru.tools._shared._thread_tool``) must NOT count the time a human spends
352
+ # deciding on a permission prompt. The danger is concrete: if the timeout
353
+ # fired while a prompt was still open, ``asyncio`` reports a timeout to the
354
+ # model — but the worker thread it ran on CANNOT be killed (a Python
355
+ # limitation), so it keeps running, parked on the prompt. The moment the user
356
+ # clicks "yes", that orphaned thread applies the mutation **out-of-band**,
357
+ # after the tool already claimed it timed out. An edit (or a delete) then
358
+ # lands that the user never knowingly approved in-context.
359
+ #
360
+ # To prevent that, ``check_permission`` marks a per-tool-call gate while it
361
+ # blocks on the user, and ``_thread_tool`` suspends its timeout for exactly
362
+ # as long as the gate is active. Decision time is the human's, not the
363
+ # tool's budget.
364
+ #
365
+ # Cross-thread mechanics: the gate object is created per tool call by the
366
+ # ``_thread_tool`` wrapper (on the event loop) and stored in a ContextVar.
367
+ # ``asyncio.to_thread`` copies the context into the worker thread, so
368
+ # ``check_permission`` running on that thread flips the SAME gate object the
369
+ # wrapper polls. Concurrent tool calls each get their own gate, so a prompt
370
+ # open for one call never accidentally exempts a sibling.
371
+
372
+
373
+ class PermissionWaitGate:
374
+ """Per-tool-call counter of in-flight human permission decisions.
375
+
376
+ A depth counter (not a bool) so re-entrant or repeated permission checks
377
+ within one tool call nest correctly. ``active`` is true whenever at least
378
+ one decision is outstanding.
379
+ """
380
+
381
+ __slots__ = ("_depth", "_lock")
382
+
383
+ def __init__(self) -> None:
384
+ self._depth = 0
385
+ self._lock = threading.Lock()
386
+
387
+ @property
388
+ def active(self) -> bool:
389
+ with self._lock:
390
+ return self._depth > 0
391
+
392
+ def enter(self) -> None:
393
+ with self._lock:
394
+ self._depth += 1
395
+
396
+ def leave(self) -> None:
397
+ with self._lock:
398
+ if self._depth > 0:
399
+ self._depth -= 1
400
+
401
+
402
+ _perm_wait_gate: contextvars.ContextVar[PermissionWaitGate | None] = (
403
+ contextvars.ContextVar("aru_perm_wait_gate", default=None)
404
+ )
405
+
406
+
407
+ def install_permission_wait_gate() -> tuple[PermissionWaitGate, contextvars.Token]:
408
+ """Create a fresh gate, install it in the current context, return (gate, token).
409
+
410
+ Called by the ``_thread_tool`` wrapper before offloading to a worker
411
+ thread so the worker (which runs in a copy of this context) shares the
412
+ gate. Pair with ``reset_permission_wait_gate(token)`` in a finally.
413
+ """
414
+ gate = PermissionWaitGate()
415
+ token = _perm_wait_gate.set(gate)
416
+ return gate, token
417
+
418
+
419
+ def reset_permission_wait_gate(token: contextvars.Token) -> None:
420
+ """Restore the previous gate binding (undo ``install_permission_wait_gate``)."""
421
+ _perm_wait_gate.reset(token)
422
+
423
+
424
+ def begin_permission_wait() -> None:
425
+ """Mark that the current tool call is blocking on a human permission decision.
426
+
427
+ Paired with ``end_permission_wait()``. A no-op when no gate is installed
428
+ (async tools like ``bash`` that aren't wrapped by ``_thread_tool``, or
429
+ direct test calls) — those paths have no execution timeout to suspend.
430
+ """
431
+ gate = _perm_wait_gate.get()
432
+ if gate is not None:
433
+ gate.enter()
434
+
435
+
436
+ def end_permission_wait() -> None:
437
+ """End the permission-wait window opened by ``begin_permission_wait()``."""
438
+ gate = _perm_wait_gate.get()
439
+ if gate is not None:
440
+ gate.leave()
441
+
442
+
348
443
  # ── Shared-state helpers (Stage 4) ───────────────────────────────────
349
444
  #
350
445
  # Individual ``dict[k] = v``, ``dict.get(k)``, and ``list.append`` are atomic
@@ -0,0 +1,145 @@
1
+ """Shared helpers used by multiple tool modules.
2
+
3
+ Split out of the former monolithic codebase.py. Imported by file_ops, search,
4
+ shell, web, and delegate. Intentionally has no dependencies on other tool
5
+ submodules so it sits at the bottom of the tool dependency graph.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import functools
12
+
13
+ from aru.runtime import get_ctx
14
+ from aru.tools.gitignore import invalidate_walk_cache
15
+
16
+
17
+ _MAX_OUTPUT_CHARS = 10_000
18
+ _TRUNCATE_KEEP = 3_000 # chars to keep from start and end
19
+
20
+ # How often (seconds) the timeout loop re-checks the permission gate while a
21
+ # human decision is in flight. Small enough that the post-decision write is
22
+ # noticed promptly; large enough not to busy-spin while the user is thinking.
23
+ _PERM_POLL_SLICE = 0.1
24
+
25
+
26
+ def _notify_file_mutation(*, path: str | None = None, mutation_type: str = "unknown"):
27
+ """Notify the session that files changed so caches are invalidated.
28
+
29
+ Also publishes ``file.changed`` via the plugin bus so plugins (auto-
30
+ linter, memory extractor, LSP didChange etc.) can react. ``path`` and
31
+ ``mutation_type`` are optional and default to "unknown" for legacy
32
+ callers that haven't been updated yet.
33
+ """
34
+ ctx = get_ctx()
35
+ ctx.read_cache.clear()
36
+ invalidate_walk_cache()
37
+ if ctx.on_file_mutation:
38
+ ctx.on_file_mutation()
39
+ from aru.runtime import _schedule_publish
40
+ _schedule_publish("file.changed", {
41
+ "path": path, "mutation_type": mutation_type,
42
+ })
43
+
44
+
45
+ def _checkpoint_file(file_path: str):
46
+ """Capture pre-edit state of a file for undo support.
47
+
48
+ Must be called BEFORE writing/editing the file.
49
+ """
50
+ ctx = get_ctx()
51
+ if ctx.checkpoint_manager:
52
+ ctx.checkpoint_manager.track_edit(file_path)
53
+
54
+
55
+ def _get_small_model_ref() -> str:
56
+ """Get the small model reference for sub-agents."""
57
+ return get_ctx().small_model_ref
58
+
59
+
60
+ def _truncate_output(text: str, source_file: str = "", source_tool: str = "") -> str:
61
+ """Truncate long tool output to save tokens. Keeps start + end with a marker in the middle."""
62
+ from aru.context import truncate_output
63
+ return truncate_output(text, source_file=source_file, source_tool=source_tool)
64
+
65
+
66
+ def _thread_tool(sync_fn, *, timeout: float | None = None):
67
+ """Wrap *sync_fn* as an async tool that runs on a worker thread.
68
+
69
+ ``functools.wraps`` copies ``__name__``/``__doc__`` so Agno introspects
70
+ the wrapper as if it were the original sync function — tool name and
71
+ signature match what the LLM already knows.
72
+
73
+ Args:
74
+ sync_fn: The synchronous implementation to offload to a worker.
75
+ timeout: Optional wall-clock cap (seconds). ``None`` (default) keeps
76
+ the historical behaviour of unbounded wait — callers opt into a
77
+ cap explicitly. Required because ``asyncio.to_thread`` cannot
78
+ actually abort the underlying worker thread (Python limitation):
79
+ on timeout, the REPL regains control but the thread may keep
80
+ running until its sync work finishes. Applying a blanket
81
+ default would break custom plugin tools that legitimately take
82
+ longer than the cap.
83
+
84
+ Permission-wait suspension (safety-critical): if the wrapped tool calls
85
+ ``check_permission`` and blocks on a human decision, the timeout is
86
+ suspended for the duration of that prompt. Without this, the timeout
87
+ could fire mid-prompt, report a timeout to the model, and leave the
88
+ worker thread alive to apply the mutation out-of-band once the user
89
+ finally answered. See ``aru.runtime.PermissionWaitGate``.
90
+ """
91
+
92
+ @functools.wraps(sync_fn)
93
+ async def wrapper(*args, **kwargs):
94
+ if timeout is None:
95
+ return await asyncio.to_thread(sync_fn, *args, **kwargs)
96
+
97
+ from aru.runtime import (
98
+ install_permission_wait_gate,
99
+ reset_permission_wait_gate,
100
+ )
101
+
102
+ # Install the per-call gate BEFORE offloading so the worker thread
103
+ # (which runs in a copy of this context) shares the same gate object
104
+ # and ``check_permission`` can flip it while it blocks on the user.
105
+ gate, token = install_permission_wait_gate()
106
+ task: asyncio.Future | None = None
107
+ try:
108
+ loop = asyncio.get_running_loop()
109
+ task = asyncio.ensure_future(asyncio.to_thread(sync_fn, *args, **kwargs))
110
+ deadline = loop.time() + timeout
111
+ while True:
112
+ if task.done():
113
+ return task.result()
114
+ now = loop.time()
115
+ if gate.active:
116
+ # A human is being asked to approve this tool call. Their
117
+ # decision time is not the tool's execution budget — and
118
+ # abandoning the worker thread now would let it apply the
119
+ # mutation the instant the user answers, after we already
120
+ # reported a timeout. Hold the deadline a full window
121
+ # ahead so that, once the prompt closes, the actual work
122
+ # still gets the complete budget (this closes the
123
+ # answer→write race: when ``gate.active`` flips false the
124
+ # deadline is at most one poll-slice old).
125
+ deadline = now + timeout
126
+ await asyncio.wait({task}, timeout=_PERM_POLL_SLICE)
127
+ continue
128
+ remaining = deadline - now
129
+ if remaining <= 0:
130
+ # Genuine timeout — no human in the loop. Request
131
+ # cancellation (best-effort; the OS thread may run on,
132
+ # same as the historical behaviour) and surface a string.
133
+ task.cancel()
134
+ return (
135
+ f"[Tool timeout: {sync_fn.__name__} exceeded {timeout:g}s. "
136
+ f"The worker thread may still be running in the background; "
137
+ f"narrow the query or raise the timeout explicitly.]"
138
+ )
139
+ await asyncio.wait({task}, timeout=remaining)
140
+ finally:
141
+ if task is not None and not task.done():
142
+ task.cancel()
143
+ reset_permission_wait_gate(token)
144
+
145
+ return wrapper
@@ -27,6 +27,10 @@ import shutil
27
27
  from dataclasses import dataclass, field
28
28
  from pathlib import Path
29
29
 
30
+ from rich.console import Group
31
+ from rich.text import Text
32
+
33
+ from aru.permissions import check_permission, consume_rejection_feedback
30
34
  from aru.tools._shared import _checkpoint_file, _notify_file_mutation
31
35
 
32
36
 
@@ -499,7 +503,86 @@ except OSError:
499
503
  _PROMPT_TEXT = "Apply a multi-file patch atomically (see apply_patch_prompt.txt)."
500
504
 
501
505
 
506
+ _PREVIEW_MAX_ADD_LINES = 40 # cap new-file body shown in the prompt
507
+
508
+
509
+ def _patch_permission_preview(patch: Patch) -> tuple[list[str], Group]:
510
+ """Build the (subjects, renderable) pair for the permission prompt.
511
+
512
+ Subjects are the affected paths (one per op, plus the move target) so the
513
+ user's per-path rules apply. The renderable shows a real diff — the actual
514
+ +/- lines for each Update hunk and Add body — so the user approves what
515
+ they can see, not a blind "update". Deletes and moves are highlighted;
516
+ those are the irreversible ones. New-file bodies are capped so a large
517
+ patch can't flood the terminal.
518
+ """
519
+ subjects: list[str] = []
520
+ blocks: list[Text] = [
521
+ Text(f"apply_patch — {len(patch.operations)} operation(s):", style="bold"),
522
+ Text(),
523
+ ]
524
+ for op in patch.operations:
525
+ if isinstance(op, AddFile):
526
+ subjects.append(op.path)
527
+ blocks.append(Text(f"+ add {op.path}", style="bold green"))
528
+ body = op.content.splitlines()
529
+ for line in body[:_PREVIEW_MAX_ADD_LINES]:
530
+ blocks.append(Text(f" +{line}", style="green"))
531
+ if len(body) > _PREVIEW_MAX_ADD_LINES:
532
+ blocks.append(Text(f" … (+{len(body) - _PREVIEW_MAX_ADD_LINES} more lines)", style="dim"))
533
+ elif isinstance(op, DeleteFile):
534
+ subjects.append(op.path)
535
+ blocks.append(Text(f"- delete {op.path}", style="bold red"))
536
+ elif isinstance(op, UpdateFile):
537
+ subjects.append(op.path)
538
+ header = f"~ update {op.path}"
539
+ if op.move_to:
540
+ subjects.append(op.move_to)
541
+ header += f" → {op.move_to}"
542
+ blocks.append(Text(header, style="bold yellow"))
543
+ for hunk in op.hunks:
544
+ if hunk.anchor:
545
+ blocks.append(Text(f" @@ {hunk.anchor}", style="cyan"))
546
+ for tag, text in hunk.lines:
547
+ if tag == "+":
548
+ blocks.append(Text(f" +{text}", style="green"))
549
+ elif tag == "-":
550
+ blocks.append(Text(f" -{text}", style="red"))
551
+ else:
552
+ blocks.append(Text(f" {text}", style="dim"))
553
+ blocks.append(Text())
554
+ return subjects, Group(*blocks)
555
+
556
+
502
557
  def apply_patch(patch: str) -> str:
558
+ # Parse + validate FIRST (neither touches disk for writes — validate only
559
+ # reads). This lets us reject malformed / non-applicable patches without
560
+ # bothering the user, and show exactly which files will change. ONLY then
561
+ # do we gate on permission, and only on approval do we apply. This is the
562
+ # security boundary: apply_patch can delete and move files, so it must
563
+ # never mutate the tree without an explicit allow.
564
+ try:
565
+ parsed = parse_patch(patch)
566
+ validate(parsed)
567
+ except PatchParseError as exc:
568
+ return f"Parse error: {exc}"
569
+ except PatchValidationError as exc:
570
+ return f"Validation error (no files modified): {exc}"
571
+
572
+ subjects, preview = _patch_permission_preview(parsed)
573
+ if not check_permission("edit", subjects, preview):
574
+ feedback = consume_rejection_feedback()
575
+ action = f"apply_patch ({len(parsed.operations)} operation(s))"
576
+ if feedback:
577
+ return (
578
+ f"PERMISSION DENIED by user: {action}. The user said: {feedback}\n"
579
+ f"Follow the user's instructions instead of retrying."
580
+ )
581
+ return (
582
+ f"PERMISSION DENIED by user: {action}. Do NOT retry this operation. "
583
+ f"Stop and ask the user for new instructions."
584
+ )
585
+
503
586
  try:
504
587
  return apply_patch_text(patch)
505
588
  except PatchParseError as exc:
@@ -8,12 +8,17 @@ strings rather than raises — the model can fall back to grep-based tools.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import asyncio
11
12
  import logging
12
13
  import os
13
14
 
15
+ from rich.console import Group
16
+ from rich.text import Text
17
+
14
18
  from aru.lsp.client import LspRequestError
15
19
  from aru.lsp.manager import get_lsp_manager
16
20
  from aru.lsp.protocol import Location, Position, path_to_uri, uri_to_path
21
+ from aru.permissions import check_permission, consume_rejection_feedback
17
22
 
18
23
  logger = logging.getLogger("aru.lsp")
19
24
 
@@ -194,6 +199,30 @@ async def lsp_rename(file_path: str, line: int, column: int, new_name: str) -> s
194
199
  if not per_file_edits:
195
200
  return "No files to edit."
196
201
 
202
+ # Gate on permission before touching any file — a rename rewrites every
203
+ # referencing file across the workspace, so the user must approve the
204
+ # full set. ``check_permission`` is sync and may open a TUI modal that
205
+ # blocks on ``threading.Event``; calling it directly from this async tool
206
+ # (running on the App loop) would deadlock the loop so the modal never
207
+ # resolves. Hop to a worker thread — same pattern as ``bash``.
208
+ paths = sorted({uri_to_path(uri) for uri in per_file_edits})
209
+ preview = Group(
210
+ Text(f"Rename symbol → {new_name!r} across {len(paths)} file(s):", style="bold"),
211
+ *[Text(f" ~ {p}") for p in paths],
212
+ )
213
+ if not await asyncio.to_thread(check_permission, "edit", paths, preview):
214
+ feedback = consume_rejection_feedback()
215
+ if feedback:
216
+ return (
217
+ f"PERMISSION DENIED by user: lsp_rename → {new_name!r}. "
218
+ f"The user said: {feedback}\n"
219
+ f"Follow the user's instructions instead of retrying."
220
+ )
221
+ return (
222
+ f"PERMISSION DENIED by user: lsp_rename → {new_name!r}. Do NOT retry "
223
+ f"this operation. Stop and ask the user for new instructions."
224
+ )
225
+
197
226
  applied = _apply_workspace_edit(per_file_edits)
198
227
  if isinstance(applied, str): # error path
199
228
  return applied
@@ -42,6 +42,15 @@ from aru.tools.worktree import worktree_info
42
42
 
43
43
  _rank_files_tool = _thread_tool(rank_files, timeout=45)
44
44
 
45
+ # apply_patch now prompts for permission (it can delete/move files). The
46
+ # prompt blocks on a threading.Event, which would deadlock the event loop if
47
+ # the raw sync function ran there — so wrap it like the other mutating file
48
+ # tools: run on a worker thread (loop stays free to drive the modal) and pick
49
+ # up the permission-wait timeout suspension for free. functools.wraps keeps
50
+ # __name__ == "apply_patch" so the registry key and LLM-facing schema are
51
+ # unchanged.
52
+ _apply_patch_tool = _thread_tool(apply_patch, timeout=60)
53
+
45
54
 
46
55
  # Tool sets composed from a single core set — avoid duplication and drift.
47
56
  _READ_ONLY_TOOLS = [
@@ -63,7 +72,7 @@ _WRITE_TOOLS = [
63
72
  _write_files_tool,
64
73
  _edit_file_tool,
65
74
  _edit_files_tool,
66
- apply_patch,
75
+ _apply_patch_tool,
67
76
  lsp_rename,
68
77
  ]
69
78
 
@@ -138,19 +138,27 @@ class TuiUI:
138
138
  result["error"] = f"mount failed: {exc}"
139
139
  done.set()
140
140
 
141
+ # Hide the "thinking…" spinner while the prompt owns the screen — we
142
+ # are parked waiting on the user, not computing, so an animated
143
+ # spinner there is misleading. Restored in the finally regardless of
144
+ # how the wait ends.
145
+ self._suppress_thinking()
141
146
  try:
142
- self.app.call_from_thread(_mount)
143
- except Exception as exc:
144
- raise RuntimeError(
145
- f"TuiUI inline-choice dispatch failed: "
146
- f"{type(exc).__name__}: {exc}"
147
- ) from exc
148
-
149
- if not done.wait(timeout=timeout_s):
150
- raise RuntimeError(
151
- f"TuiUI inline-choice timed out after {timeout_s:.0f}s"
152
- )
153
- return result.get("value")
147
+ try:
148
+ self.app.call_from_thread(_mount)
149
+ except Exception as exc:
150
+ raise RuntimeError(
151
+ f"TuiUI inline-choice dispatch failed: "
152
+ f"{type(exc).__name__}: {exc}"
153
+ ) from exc
154
+
155
+ if not done.wait(timeout=timeout_s):
156
+ raise RuntimeError(
157
+ f"TuiUI inline-choice timed out after {timeout_s:.0f}s"
158
+ )
159
+ return result.get("value")
160
+ finally:
161
+ self._release_thinking()
154
162
 
155
163
  # ── confirm ───────────────────────────────────────────────────────
156
164
 
@@ -208,6 +216,34 @@ class TuiUI:
208
216
 
209
217
  # ── internal ──────────────────────────────────────────────────────
210
218
 
219
+ def _suppress_thinking(self) -> None:
220
+ """Hide the ThinkingIndicator while a blocking prompt is open.
221
+
222
+ Safe to call from a tool worker thread — the widget mutation is
223
+ marshalled onto the App loop. Best-effort: any failure (App shutting
224
+ down, indicator not mounted) is swallowed so a UI hiccup never blocks
225
+ a permission decision. Paired with ``_release_thinking``.
226
+ """
227
+ try:
228
+ from aru.tui.widgets.thinking import ThinkingIndicator
229
+
230
+ self.app.call_from_thread(
231
+ lambda: self.app.query_one(ThinkingIndicator).suppress()
232
+ )
233
+ except Exception:
234
+ pass
235
+
236
+ def _release_thinking(self) -> None:
237
+ """Undo one ``_suppress_thinking`` once the prompt closes."""
238
+ try:
239
+ from aru.tui.widgets.thinking import ThinkingIndicator
240
+
241
+ self.app.call_from_thread(
242
+ lambda: self.app.query_one(ThinkingIndicator).release()
243
+ )
244
+ except Exception:
245
+ pass
246
+
211
247
  def _run_modal(self, modal: Any, timeout_s: float = 300.0) -> Any:
212
248
  """Push a ModalScreen and block until it is dismissed.
213
249
 
@@ -225,15 +261,21 @@ class TuiUI:
225
261
  result["value"] = value
226
262
  done.set()
227
263
 
264
+ # Park the spinner while the modal owns the screen (see
265
+ # _run_inline_choice). Restored in the finally.
266
+ self._suppress_thinking()
228
267
  try:
229
- self.app.call_from_thread(self.app.push_screen, modal, _on_dismiss)
230
- except Exception as e:
231
- raise RuntimeError(
232
- f"TuiUI modal dispatch failed: {type(e).__name__}: {e}"
233
- ) from e
234
-
235
- if not done.wait(timeout=timeout_s):
236
- raise RuntimeError(
237
- f"TuiUI modal timed out after {timeout_s:.0f}s"
238
- )
239
- return result.get("value")
268
+ try:
269
+ self.app.call_from_thread(self.app.push_screen, modal, _on_dismiss)
270
+ except Exception as e:
271
+ raise RuntimeError(
272
+ f"TuiUI modal dispatch failed: {type(e).__name__}: {e}"
273
+ ) from e
274
+
275
+ if not done.wait(timeout=timeout_s):
276
+ raise RuntimeError(
277
+ f"TuiUI modal timed out after {timeout_s:.0f}s"
278
+ )
279
+ return result.get("value")
280
+ finally:
281
+ self._release_thinking()
@@ -54,6 +54,14 @@ class ThinkingIndicator(Widget):
54
54
  TICK_SECONDS: float = 0.1
55
55
 
56
56
  busy: reactive[bool] = reactive(False, layout=True)
57
+ # Incremented while a blocking prompt (permission / confirm / text input)
58
+ # owns the screen. The spinner means "a turn is in flight", but while we
59
+ # are parked waiting on the user there is nothing to animate — showing it
60
+ # there reads as "still processing" and is misleading. The indicator is
61
+ # hidden whenever suppress_depth > 0, regardless of ``busy``. A depth
62
+ # counter (not a bool) keeps back-to-back prompts (e.g. "No" → feedback
63
+ # box) suppressed throughout, without a flash between them.
64
+ suppress_depth: reactive[int] = reactive(0, layout=True)
57
65
 
58
66
  def __init__(self) -> None:
59
67
  super().__init__()
@@ -66,16 +74,35 @@ class ThinkingIndicator(Widget):
66
74
  def on_mount(self) -> None:
67
75
  self.set_interval(self.TICK_SECONDS, self._tick)
68
76
 
77
+ def _visible(self) -> bool:
78
+ return self.busy and self.suppress_depth == 0
79
+
80
+ def _apply_visibility(self) -> None:
81
+ if self._visible():
82
+ self.add_class("-busy")
83
+ else:
84
+ self.remove_class("-busy")
85
+
69
86
  def watch_busy(self, _old: bool, new: bool) -> None:
70
87
  if new:
71
- self.add_class("-busy")
72
88
  self._index = 0
73
89
  self._ticks_since_rotate = 0
74
- else:
75
- self.remove_class("-busy")
90
+ self._apply_visibility()
91
+
92
+ def watch_suppress_depth(self, _old: int, _new: int) -> None:
93
+ self._apply_visibility()
94
+
95
+ def suppress(self) -> None:
96
+ """Hide the spinner while a blocking prompt owns the screen."""
97
+ self.suppress_depth += 1
98
+
99
+ def release(self) -> None:
100
+ """Undo one ``suppress()``; spinner returns only if the turn is still busy."""
101
+ if self.suppress_depth > 0:
102
+ self.suppress_depth -= 1
76
103
 
77
104
  def _tick(self) -> None:
78
- if not self.busy:
105
+ if not self._visible():
79
106
  return
80
107
  self._ticks_since_rotate += 1
81
108
  if self._ticks_since_rotate * self.TICK_SECONDS >= self.ROTATE_SECONDS:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.54.0
3
+ Version: 0.55.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
@@ -151,6 +151,7 @@ tests/test_mcp_health.py
151
151
  tests/test_memory.py
152
152
  tests/test_memory_tool.py
153
153
  tests/test_microcompact.py
154
+ tests/test_permission_timeout_suspension.py
154
155
  tests/test_permissions.py
155
156
  tests/test_plan_mode_refactor.py
156
157
  tests/test_plugin_cache.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.54.0"
7
+ version = "0.55.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"