aru-code 0.55.0__tar.gz → 0.57.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 (212) hide show
  1. {aru_code-0.55.0/aru_code.egg-info → aru_code-0.57.0}/PKG-INFO +1 -1
  2. aru_code-0.57.0/aru/__init__.py +1 -0
  3. {aru_code-0.55.0 → aru_code-0.57.0}/aru/display.py +6 -3
  4. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/plan_mode.py +13 -1
  5. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/app.py +35 -4
  6. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/slash_bridge.py +11 -1
  7. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/ui.py +51 -0
  8. {aru_code-0.55.0 → aru_code-0.57.0/aru_code.egg-info}/PKG-INFO +1 -1
  9. {aru_code-0.55.0 → aru_code-0.57.0}/pyproject.toml +1 -1
  10. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_plan_mode_refactor.py +54 -0
  11. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_permission_flow.py +145 -0
  12. aru_code-0.55.0/aru/__init__.py +0 -1
  13. {aru_code-0.55.0 → aru_code-0.57.0}/LICENSE +0 -0
  14. {aru_code-0.55.0 → aru_code-0.57.0}/README.md +0 -0
  15. {aru_code-0.55.0 → aru_code-0.57.0}/aru/_debug/__init__.py +0 -0
  16. {aru_code-0.55.0 → aru_code-0.57.0}/aru/_debug/analyze_trace.py +0 -0
  17. {aru_code-0.55.0 → aru_code-0.57.0}/aru/_debug/loop_tracer.py +0 -0
  18. {aru_code-0.55.0 → aru_code-0.57.0}/aru/agent_factory.py +0 -0
  19. {aru_code-0.55.0 → aru_code-0.57.0}/aru/agents/__init__.py +0 -0
  20. {aru_code-0.55.0 → aru_code-0.57.0}/aru/agents/base.py +0 -0
  21. {aru_code-0.55.0 → aru_code-0.57.0}/aru/agents/catalog.py +0 -0
  22. {aru_code-0.55.0 → aru_code-0.57.0}/aru/agents/planner.py +0 -0
  23. {aru_code-0.55.0 → aru_code-0.57.0}/aru/cache_patch.py +0 -0
  24. {aru_code-0.55.0 → aru_code-0.57.0}/aru/checkpoints.py +0 -0
  25. {aru_code-0.55.0 → aru_code-0.57.0}/aru/cli.py +0 -0
  26. {aru_code-0.55.0 → aru_code-0.57.0}/aru/commands.py +0 -0
  27. {aru_code-0.55.0 → aru_code-0.57.0}/aru/completers.py +0 -0
  28. {aru_code-0.55.0 → aru_code-0.57.0}/aru/config.py +0 -0
  29. {aru_code-0.55.0 → aru_code-0.57.0}/aru/context.py +0 -0
  30. {aru_code-0.55.0 → aru_code-0.57.0}/aru/doom_loop.py +0 -0
  31. {aru_code-0.55.0 → aru_code-0.57.0}/aru/events.py +0 -0
  32. {aru_code-0.55.0 → aru_code-0.57.0}/aru/format/__init__.py +0 -0
  33. {aru_code-0.55.0 → aru_code-0.57.0}/aru/format/manager.py +0 -0
  34. {aru_code-0.55.0 → aru_code-0.57.0}/aru/format/runner.py +0 -0
  35. {aru_code-0.55.0 → aru_code-0.57.0}/aru/history_blocks.py +0 -0
  36. {aru_code-0.55.0 → aru_code-0.57.0}/aru/lsp/__init__.py +0 -0
  37. {aru_code-0.55.0 → aru_code-0.57.0}/aru/lsp/client.py +0 -0
  38. {aru_code-0.55.0 → aru_code-0.57.0}/aru/lsp/manager.py +0 -0
  39. {aru_code-0.55.0 → aru_code-0.57.0}/aru/lsp/protocol.py +0 -0
  40. {aru_code-0.55.0 → aru_code-0.57.0}/aru/memory/__init__.py +0 -0
  41. {aru_code-0.55.0 → aru_code-0.57.0}/aru/memory/extractor.py +0 -0
  42. {aru_code-0.55.0 → aru_code-0.57.0}/aru/memory/loader.py +0 -0
  43. {aru_code-0.55.0 → aru_code-0.57.0}/aru/memory/store.py +0 -0
  44. {aru_code-0.55.0 → aru_code-0.57.0}/aru/permissions.py +0 -0
  45. {aru_code-0.55.0 → aru_code-0.57.0}/aru/plugin_cache.py +0 -0
  46. {aru_code-0.55.0 → aru_code-0.57.0}/aru/plugins/__init__.py +0 -0
  47. {aru_code-0.55.0 → aru_code-0.57.0}/aru/plugins/custom_tools.py +0 -0
  48. {aru_code-0.55.0 → aru_code-0.57.0}/aru/plugins/hooks.py +0 -0
  49. {aru_code-0.55.0 → aru_code-0.57.0}/aru/plugins/manager.py +0 -0
  50. {aru_code-0.55.0 → aru_code-0.57.0}/aru/plugins/tool_api.py +0 -0
  51. {aru_code-0.55.0 → aru_code-0.57.0}/aru/providers.py +0 -0
  52. {aru_code-0.55.0 → aru_code-0.57.0}/aru/runner.py +0 -0
  53. {aru_code-0.55.0 → aru_code-0.57.0}/aru/runtime.py +0 -0
  54. {aru_code-0.55.0 → aru_code-0.57.0}/aru/select.py +0 -0
  55. {aru_code-0.55.0 → aru_code-0.57.0}/aru/session.py +0 -0
  56. {aru_code-0.55.0 → aru_code-0.57.0}/aru/sinks.py +0 -0
  57. {aru_code-0.55.0 → aru_code-0.57.0}/aru/streaming.py +0 -0
  58. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tool_policy.py +0 -0
  59. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/__init__.py +0 -0
  60. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/_diff.py +0 -0
  61. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/_shared.py +0 -0
  62. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/apply_patch.py +0 -0
  63. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/apply_patch_prompt.txt +0 -0
  64. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/ast_tools.py +0 -0
  65. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/codebase.py +0 -0
  66. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/delegate.py +0 -0
  67. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/delegate_prompt.txt +0 -0
  68. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/file_ops.py +0 -0
  69. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/gitignore.py +0 -0
  70. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/lsp.py +0 -0
  71. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/mcp_client.py +0 -0
  72. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/memory_tool.py +0 -0
  73. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/ranker.py +0 -0
  74. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/registry.py +0 -0
  75. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/search.py +0 -0
  76. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/shell.py +0 -0
  77. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/skill.py +0 -0
  78. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/tasklist.py +0 -0
  79. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/web.py +0 -0
  80. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tools/worktree.py +0 -0
  81. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/__init__.py +0 -0
  82. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/log_bridge.py +0 -0
  83. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/notifications.py +0 -0
  84. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/sanitize.py +0 -0
  85. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/screens/__init__.py +0 -0
  86. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/screens/choice.py +0 -0
  87. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/screens/confirm.py +0 -0
  88. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/screens/keymap.py +0 -0
  89. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/screens/search.py +0 -0
  90. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/screens/session_picker.py +0 -0
  91. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/screens/text_input.py +0 -0
  92. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/sinks.py +0 -0
  93. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/themes.py +0 -0
  94. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/__init__.py +0 -0
  95. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/chat.py +0 -0
  96. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/completer.py +0 -0
  97. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/context_pane.py +0 -0
  98. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/file_link.py +0 -0
  99. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/header.py +0 -0
  100. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/inline_choice.py +0 -0
  101. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/loaded_pane.py +0 -0
  102. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/prompt_area.py +0 -0
  103. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/prompt_queue.py +0 -0
  104. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/status.py +0 -0
  105. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/subagent_panel.py +0 -0
  106. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/tasklist_panel.py +0 -0
  107. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/thinking.py +0 -0
  108. {aru_code-0.55.0 → aru_code-0.57.0}/aru/tui/widgets/tools.py +0 -0
  109. {aru_code-0.55.0 → aru_code-0.57.0}/aru/ui.py +0 -0
  110. {aru_code-0.55.0 → aru_code-0.57.0}/aru_code.egg-info/SOURCES.txt +0 -0
  111. {aru_code-0.55.0 → aru_code-0.57.0}/aru_code.egg-info/dependency_links.txt +0 -0
  112. {aru_code-0.55.0 → aru_code-0.57.0}/aru_code.egg-info/entry_points.txt +0 -0
  113. {aru_code-0.55.0 → aru_code-0.57.0}/aru_code.egg-info/requires.txt +0 -0
  114. {aru_code-0.55.0 → aru_code-0.57.0}/aru_code.egg-info/top_level.txt +0 -0
  115. {aru_code-0.55.0 → aru_code-0.57.0}/setup.cfg +0 -0
  116. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_agents_base.py +0 -0
  117. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_agents_md_coverage.py +0 -0
  118. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_apply_patch.py +0 -0
  119. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_async_tool_permission.py +0 -0
  120. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cache_patch_metrics.py +0 -0
  121. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cache_patch_stop_reason.py +0 -0
  122. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_catalog.py +0 -0
  123. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_chat_scrollable.py +0 -0
  124. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_checkpoints.py +0 -0
  125. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cli.py +0 -0
  126. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cli_advanced.py +0 -0
  127. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cli_base.py +0 -0
  128. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cli_completers.py +0 -0
  129. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cli_new.py +0 -0
  130. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cli_run_cli.py +0 -0
  131. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cli_session.py +0 -0
  132. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cli_shell.py +0 -0
  133. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_codebase.py +0 -0
  134. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_confabulation_regression.py +0 -0
  135. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_config.py +0 -0
  136. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_context.py +0 -0
  137. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_context_pane.py +0 -0
  138. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_cwd_awareness.py +0 -0
  139. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_delegate.py +0 -0
  140. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_doom_loop.py +0 -0
  141. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_events_backward_compat.py +0 -0
  142. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_events_schema.py +0 -0
  143. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_fork_ctx_concurrency.py +0 -0
  144. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_format.py +0 -0
  145. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_gitignore.py +0 -0
  146. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_guardrails_scenarios.py +0 -0
  147. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_invoke_skill.py +0 -0
  148. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_invoked_skills.py +0 -0
  149. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_loaded_pane_path.py +0 -0
  150. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_lsp.py +0 -0
  151. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_lsp_rename.py +0 -0
  152. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_main.py +0 -0
  153. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_markdown_to_text.py +0 -0
  154. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_mcp_client.py +0 -0
  155. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_mcp_health.py +0 -0
  156. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_memory.py +0 -0
  157. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_memory_tool.py +0 -0
  158. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_microcompact.py +0 -0
  159. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_permission_timeout_suspension.py +0 -0
  160. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_permissions.py +0 -0
  161. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_plugin_cache.py +0 -0
  162. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_plugin_errors.py +0 -0
  163. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_plugin_hooks_v2.py +0 -0
  164. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_plugins.py +0 -0
  165. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_providers.py +0 -0
  166. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_ranker.py +0 -0
  167. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_reasoning.py +0 -0
  168. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_runner_interrupt.py +0 -0
  169. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_runner_recovery.py +0 -0
  170. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_runtime.py +0 -0
  171. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_select.py +0 -0
  172. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_session_free_cost.py +0 -0
  173. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_skill_disallowed_tools.py +0 -0
  174. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_status_breakdown.py +0 -0
  175. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_status_cost.py +0 -0
  176. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_streaming_sink.py +0 -0
  177. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_subagent_tool_events.py +0 -0
  178. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tasklist.py +0 -0
  179. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_thread_tool_timeout.py +0 -0
  180. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tool_policy.py +0 -0
  181. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_truncation_marker.py +0 -0
  182. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_app_boot.py +0 -0
  183. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_bindings.py +0 -0
  184. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_bus_flow.py +0 -0
  185. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_chat.py +0 -0
  186. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_chat_adversarial.py +0 -0
  187. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_completer.py +0 -0
  188. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_completer_dynamic.py +0 -0
  189. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_copy.py +0 -0
  190. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_error_display.py +0 -0
  191. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_file_link.py +0 -0
  192. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_input_behaviour.py +0 -0
  193. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_layer12_recovery.py +0 -0
  194. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_layer13_recovery.py +0 -0
  195. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_mention_expand.py +0 -0
  196. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_modals.py +0 -0
  197. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_mode_cycle.py +0 -0
  198. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_native_selection.py +0 -0
  199. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_plan_task_render.py +0 -0
  200. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_prompt_queue.py +0 -0
  201. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_shell_bang.py +0 -0
  202. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_sidebar_toggle.py +0 -0
  203. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_slash_bridge.py +0 -0
  204. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_slash_model.py +0 -0
  205. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_snapshot_smoke.py +0 -0
  206. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_subagent_panel.py +0 -0
  207. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_theme.py +0 -0
  208. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_thinking_and_boot.py +0 -0
  209. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_tui_widgets_visual.py +0 -0
  210. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_ui_adapter.py +0 -0
  211. {aru_code-0.55.0 → aru_code-0.57.0}/tests/test_worktree.py +0 -0
  212. {aru_code-0.55.0 → aru_code-0.57.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.55.0
3
+ Version: 0.57.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.57.0"
@@ -301,13 +301,16 @@ class ToolTracker:
301
301
  self._completed: list[tuple[str, float]] = [] # (label, duration)
302
302
 
303
303
  def start(self, tool_id: str, label: str):
304
- self._active[tool_id] = (label, time.monotonic())
304
+ # perf_counter, not monotonic: on Windows monotonic() has ~15.6ms
305
+ # resolution, so a sub-tick tool call would report a 0.0 duration.
306
+ # perf_counter is monotonic too but high-resolution (~100ns).
307
+ self._active[tool_id] = (label, time.perf_counter())
305
308
 
306
309
  def complete(self, tool_id: str) -> tuple[str, float] | None:
307
310
  entry = self._active.pop(tool_id, None)
308
311
  if entry:
309
312
  label, start = entry
310
- duration = time.monotonic() - start
313
+ duration = time.perf_counter() - start
311
314
  self._completed.append((label, duration))
312
315
  return label, duration
313
316
  return None
@@ -315,7 +318,7 @@ class ToolTracker:
315
318
  @property
316
319
  def active_labels(self) -> list[tuple[str, float]]:
317
320
  """Return (label, elapsed_seconds) for each active tool."""
318
- now = time.monotonic()
321
+ now = time.perf_counter()
319
322
  return [(label, now - start) for label, start in self._active.values()]
320
323
 
321
324
  def pop_completed(self) -> list[tuple[str, float]]:
@@ -24,6 +24,7 @@ specialized read-only tool set and instructions.
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ import asyncio
27
28
  import sys
28
29
 
29
30
  from rich.panel import Panel
@@ -197,7 +198,18 @@ async def exit_plan_mode(plan: str) -> str:
197
198
  session.set_plan(task=task_label, plan_content=plan_text)
198
199
  n_steps = len(session.plan_steps)
199
200
 
200
- approved, feedback = _prompt_plan_approval(session.plan_steps, n_steps)
201
+ # ``exit_plan_mode`` is an async tool, so ``agent_factory`` awaits it
202
+ # directly on the event-loop thread (no ``_thread_tool`` worker hop like
203
+ # sync tools get). ``_prompt_plan_approval`` is synchronous and in TUI
204
+ # mode invokes ``TuiUI.ask_choice`` → ``App.call_from_thread``, which
205
+ # raises "must run in a different thread from the app" when called from
206
+ # the loop thread it targets. Hop to a worker thread first so the modal
207
+ # dispatch path matches the sync-tool and runner-auto-exit paths
208
+ # (``runner.py`` does the same). ``asyncio.to_thread`` copies the
209
+ # contextvars snapshot, so ``get_ctx()`` inside still resolves.
210
+ approved, feedback = await asyncio.to_thread(
211
+ _prompt_plan_approval, session.plan_steps, n_steps
212
+ )
201
213
 
202
214
  # The approval prompt already rendered the plan panel inline, so suppress
203
215
  # the runner's coalesced end-of-batch render to avoid a duplicate.
@@ -873,14 +873,45 @@ class AruApp(App):
873
873
  return False
874
874
 
875
875
  def _run_bridged_slash(self, name: str, body: str) -> None:
876
- """Execute a bridged REPL handler and show its output in ChatPane."""
876
+ """Execute a bridged REPL handler and show its output in ChatPane.
877
+
878
+ Dispatched as a coroutine worker so the handler runs OFF the
879
+ event-loop thread. Some bridged handlers prompt — ``/memory clear``
880
+ calls ``ctx.ui.confirm``, which bridges to the App loop via
881
+ ``App.call_from_thread`` and therefore *must* be invoked from a
882
+ thread other than the loop (otherwise it raises, or degrades to the
883
+ cancel default — see ``TuiUI._on_app_thread``). The worker hops the
884
+ handler to a thread via ``asyncio.to_thread`` so the loop stays free
885
+ to draw and service the modal, then marshals the captured output
886
+ back to the ChatPane.
887
+ """
888
+ from aru.tui.slash_bridge import BRIDGED_COMMANDS
889
+
890
+ if name.lower() not in BRIDGED_COMMANDS:
891
+ return
892
+ self.run_worker(
893
+ self._run_bridged_slash_async(name, body),
894
+ name=f"slash-{name}",
895
+ group="slash-bridge",
896
+ exclusive=False,
897
+ )
898
+
899
+ async def _run_bridged_slash_async(self, name: str, body: str) -> None:
900
+ """Worker body for :meth:`_run_bridged_slash` — see its docstring."""
877
901
  from aru.tui.slash_bridge import run_bridged
878
902
 
879
- handled, text = run_bridged(name, body, self)
903
+ # Hop to a worker thread: the handler may block on a ``ctx.ui``
904
+ # prompt whose ModalScreen dispatch needs the loop to stay
905
+ # responsive. ``asyncio.to_thread`` copies the contextvars snapshot
906
+ # so the handler's ``get_ctx()`` still resolves.
907
+ handled, text = await asyncio.to_thread(run_bridged, name, body, self)
880
908
  if not handled:
881
909
  return
882
- chat = self.query_one(ChatPane)
883
- # Prefix with the command so the user has context.
910
+ # Back on the loop after the await — safe to touch widgets.
911
+ try:
912
+ chat = self.query_one(ChatPane)
913
+ except Exception:
914
+ return
884
915
  header = f"/{name}" + (f" {body}" if body else "")
885
916
  chat.add_system_message(f"$ {header}\n{text}" if text else f"$ {header}")
886
917
 
@@ -119,7 +119,17 @@ def run_bridged(name: str, body: str, app: Any) -> tuple[bool, str]:
119
119
 
120
120
  import aru.commands as cmds_module
121
121
  original_console = cmds_module.console
122
- temp = Console(record=True, width=100, force_terminal=True, color_system=None)
122
+ # ``file=StringIO()`` keeps the live output off the raw terminal — we
123
+ # only want the recorded copy (``export_text`` below), which we mirror
124
+ # into the ChatPane. Writing to stdout here is pointless under Textual
125
+ # (it owns the screen) and outright unsafe now that the handler runs on
126
+ # a worker thread; ``record=True`` still captures everything regardless
127
+ # of the file target.
128
+ import io
129
+ temp = Console(
130
+ record=True, width=100, force_terminal=True, color_system=None,
131
+ file=io.StringIO(),
132
+ )
123
133
  cmds_module.console = temp
124
134
  try:
125
135
  handler(*args, **kwargs)
@@ -23,10 +23,14 @@ Contract notes:
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import asyncio
27
+ import logging
26
28
  from typing import Any, Sequence
27
29
 
28
30
  from aru.tui.screens import ChoiceModal, ConfirmModal, TextInputModal
29
31
 
32
+ _log = logging.getLogger("aru.tui.ui")
33
+
30
34
 
31
35
  class TuiUI:
32
36
  """UIAdapter backed by Textual ModalScreens."""
@@ -34,6 +38,28 @@ class TuiUI:
34
38
  def __init__(self, app: Any) -> None:
35
39
  self.app = app
36
40
 
41
+ @staticmethod
42
+ def _on_app_thread() -> bool:
43
+ """True when the caller is running on an asyncio event-loop thread.
44
+
45
+ ``TuiUI``'s blocking prompts bridge to the App loop via
46
+ ``App.call_from_thread``, which *requires* the caller to be on a
47
+ DIFFERENT thread than the loop (otherwise it raises rather than
48
+ deadlock). The correct callers reach us from ``asyncio.to_thread`` /
49
+ ``_thread_tool`` workers — plain threads with no running loop. If a
50
+ loop IS running in this thread, we are on the App's own event-loop
51
+ thread and must not block: that would freeze the loop so the prompt
52
+ could never be drawn or answered. The guards below degrade safely in
53
+ that case instead of surfacing the cryptic ``call_from_thread``
54
+ ``RuntimeError``. The real fix for any such caller is to wrap the
55
+ call in ``await asyncio.to_thread(ctx.ui.<method>, ...)``.
56
+ """
57
+ try:
58
+ asyncio.get_running_loop()
59
+ return True
60
+ except RuntimeError:
61
+ return False
62
+
37
63
  # ── choice ────────────────────────────────────────────────────────
38
64
 
39
65
  def ask_choice(
@@ -88,6 +114,19 @@ class TuiUI:
88
114
  user answers, mirroring ``_run_modal`` so the sync call sites
89
115
  (``check_permission``, plan approval) keep their contract.
90
116
  """
117
+ if self._on_app_thread():
118
+ # Contract violation (see ``_on_app_thread``): a caller dispatched
119
+ # this blocking prompt from the App's own event-loop thread. We
120
+ # cannot show it here, so degrade to ``cancel_value`` rather than
121
+ # crash the turn with ``call_from_thread``'s RuntimeError.
122
+ _log.warning(
123
+ "TuiUI.ask_choice invoked on the event-loop thread; returning "
124
+ "cancel_value=%r without prompting. Wrap the call site in "
125
+ "asyncio.to_thread.",
126
+ cancel_value,
127
+ )
128
+ return cancel_value
129
+
91
130
  import threading
92
131
 
93
132
  from aru.tui.widgets.chat import ChatPane
@@ -252,6 +291,18 @@ class TuiUI:
252
291
  we work without an active Textual worker context. Designed to be
253
292
  called from ``asyncio.to_thread`` tool threads.
254
293
  """
294
+ if self._on_app_thread():
295
+ # See ``_on_app_thread``: blocking on the loop thread would
296
+ # freeze the App. Degrade to ``None`` (callers map this to their
297
+ # default / cancel outcome) instead of raising.
298
+ _log.warning(
299
+ "TuiUI modal (%s) invoked on the event-loop thread; "
300
+ "dismissing without prompting. Wrap the call site in "
301
+ "asyncio.to_thread.",
302
+ type(modal).__name__,
303
+ )
304
+ return None
305
+
255
306
  import threading
256
307
 
257
308
  done = threading.Event()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.55.0
3
+ Version: 0.57.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.55.0"
7
+ version = "0.57.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -245,6 +245,60 @@ class TestExitPlanMode:
245
245
  assert "needs more detail" in result
246
246
  assert "STILL in plan mode" in result
247
247
 
248
+ @pytest.mark.asyncio
249
+ async def test_approval_prompt_runs_off_event_loop_thread(self):
250
+ """Regression: the approval prompt must run on a worker thread, never
251
+ the event-loop thread.
252
+
253
+ ``exit_plan_mode`` is an async tool awaited directly on the loop. In
254
+ TUI mode ``_prompt_plan_approval`` reaches ``TuiUI.ask_choice`` →
255
+ ``App.call_from_thread``, which raises "must run in a different
256
+ thread from the app" when called on the loop thread. The fix hops to
257
+ a worker via ``asyncio.to_thread``; this test asserts the prompt
258
+ observes a thread id different from the loop's. Before the fix the
259
+ ids matched and TUI users hit the RuntimeError.
260
+ """
261
+ import threading
262
+
263
+ from aru.runtime import RuntimeContext, _runtime_ctx as _ctx
264
+ from aru.tools import plan_mode as plan_mode_module
265
+
266
+ loop_thread_id = threading.get_ident()
267
+ captured: dict = {}
268
+
269
+ def _fake_prompt(plan_steps, n_steps):
270
+ # Runs inside exit_plan_mode's approval call. Record the thread.
271
+ captured["thread_id"] = threading.get_ident()
272
+ # Also prove get_ctx() still resolves across the to_thread hop.
273
+ from aru.runtime import get_ctx
274
+ captured["has_ctx_session"] = get_ctx().session is not None
275
+ return (True, "")
276
+
277
+ session = Session()
278
+ session.plan_mode = True
279
+ ctx = RuntimeContext()
280
+ ctx.session = session
281
+ token = _ctx.set(ctx)
282
+ with patch.object(plan_mode_module, "_prompt_plan_approval", _fake_prompt):
283
+ try:
284
+ result = await plan_mode_module.exit_plan_mode(
285
+ plan="## Steps\n1. do thing\n2. other thing"
286
+ )
287
+ finally:
288
+ _ctx.reset(token)
289
+
290
+ assert "thread_id" in captured, "approval prompt was never invoked"
291
+ assert captured["thread_id"] != loop_thread_id, (
292
+ "approval prompt ran on the event-loop thread — "
293
+ "TuiUI.ask_choice's call_from_thread would raise in TUI mode"
294
+ )
295
+ assert captured["has_ctx_session"], (
296
+ "contextvars must propagate across asyncio.to_thread so the "
297
+ "prompt can still read ctx.session"
298
+ )
299
+ assert session.plan_mode is False
300
+ assert "approved" in result.lower()
301
+
248
302
 
249
303
  # ── Tool-wrapper plan-mode gate ───────────────────────────────────────
250
304
 
@@ -9,6 +9,33 @@ import pytest
9
9
  pytest.importorskip("textual")
10
10
 
11
11
 
12
+ async def _wait_for_inline_focus(app, pilot, *, max_iter: int = 50):
13
+ """Block until the InlineChoicePrompt's OptionList owns focus.
14
+
15
+ ``query(InlineChoicePrompt)`` returns the widget as soon as it is in
16
+ the DOM, but the OptionList only gains focus (and its default
17
+ highlight) inside ``InlineChoicePrompt.on_mount`` — a message
18
+ dispatched *after* mount completes. Pressing Enter / arrow keys
19
+ before that lands sends the key nowhere useful: ``OptionSelected``
20
+ never fires and the worker thread blocks until its timeout. In
21
+ isolation the gap is sub-tick so the press always lands; under a
22
+ loaded suite the mount lifecycle slips behind detection and the test
23
+ flakes with a TimeoutError. Waiting for focus closes the race.
24
+ """
25
+ from textual.widgets import OptionList
26
+
27
+ from aru.tui.widgets.inline_choice import InlineChoicePrompt
28
+
29
+ for _ in range(max_iter):
30
+ prompts = list(app.query(InlineChoicePrompt))
31
+ if prompts:
32
+ opts = list(prompts[0].query(OptionList))
33
+ if opts and app.focused is opts[0] and opts[0].highlighted is not None:
34
+ return opts[0]
35
+ await pilot.pause(0.05)
36
+ raise AssertionError("InlineChoicePrompt OptionList never took focus")
37
+
38
+
12
39
  @pytest.mark.asyncio
13
40
  async def test_tui_ask_choice_from_worker_resolves_via_modal():
14
41
  """TuiUI.ask_choice invoked from a worker thread returns modal result.
@@ -106,6 +133,9 @@ async def test_ask_choice_with_details_uses_inline_prompt_not_modal():
106
133
  "expected InlineChoicePrompt in ChatPane"
107
134
  )
108
135
  # Press Enter — OptionList focuses on mount, default=0 highlighted.
136
+ # Wait for that focus to actually land first; querying the prompt
137
+ # only proves it is in the DOM (see _wait_for_inline_focus).
138
+ await _wait_for_inline_focus(app, pilot)
109
139
  await pilot.press("enter")
110
140
  await asyncio.wait_for(task, timeout=5.0)
111
141
  assert holder["choice"] == 0
@@ -156,6 +186,7 @@ async def test_inline_prompt_hides_input_bar_and_restores_on_answer():
156
186
  assert inp.has_class("-hidden"), (
157
187
  "input should be hidden while InlineChoicePrompt is mounted"
158
188
  )
189
+ await _wait_for_inline_focus(app, pilot)
159
190
  await pilot.press("enter")
160
191
  await asyncio.wait_for(task, timeout=5.0)
161
192
  # After the user answers, the input bar is restored.
@@ -199,6 +230,7 @@ async def test_ask_choice_inline_esc_cancels_with_cancel_value():
199
230
  await pilot.pause(0.05)
200
231
  if list(app.query_one(ChatPane).query(InlineChoicePrompt)):
201
232
  break
233
+ await _wait_for_inline_focus(app, pilot)
202
234
  await pilot.press("escape")
203
235
  await asyncio.wait_for(task, timeout=5.0)
204
236
  assert holder["choice"] == 99
@@ -257,6 +289,7 @@ async def test_auto_accept_inline_choice_updates_status_pane_mode():
257
289
  if list(chat.query(InlineChoicePrompt)):
258
290
  break
259
291
  # Option index 1 = "Yes, and auto-accept edits".
292
+ await _wait_for_inline_focus(app, pilot)
260
293
  await pilot.press("down")
261
294
  await pilot.press("enter")
262
295
  await asyncio.wait_for(task, timeout=5.0)
@@ -320,6 +353,7 @@ async def test_thinking_spinner_hidden_while_prompt_open():
320
353
  "spinner must be hidden while the permission prompt is open"
321
354
  )
322
355
 
356
+ await _wait_for_inline_focus(app, pilot)
323
357
  await pilot.press("enter")
324
358
  await asyncio.wait_for(task, timeout=5.0)
325
359
  for _ in range(20):
@@ -332,6 +366,53 @@ async def test_thinking_spinner_hidden_while_prompt_open():
332
366
  assert holder["choice"] == 0
333
367
 
334
368
 
369
+ @pytest.mark.asyncio
370
+ async def test_blocking_prompt_on_event_loop_thread_degrades_not_raises():
371
+ """Defense-in-depth (bug class): a blocking TuiUI prompt invoked directly
372
+ on the App's event-loop thread must NOT raise the cryptic
373
+ 'call_from_thread must run in a different thread' RuntimeError.
374
+
375
+ The correct fix for any *known* caller is to hop to a worker thread
376
+ (asyncio.to_thread) — this guard is the safety net that keeps a stray
377
+ on-loop call (e.g. the /memory clear slash bridge) from crashing a turn.
378
+ It degrades to cancel_value / the caller default instead.
379
+ """
380
+ from rich.panel import Panel
381
+
382
+ from aru.tui.ui import TuiUI
383
+
384
+ class _DummyApp:
385
+ # If the guard fails, _run_inline_choice/_run_modal would reach this
386
+ # and we'd see the assertion instead of a clean degrade.
387
+ def call_from_thread(self, *args, **kwargs):
388
+ raise AssertionError(
389
+ "call_from_thread must not be reached on the loop thread"
390
+ )
391
+
392
+ ui = TuiUI(_DummyApp())
393
+ # This coroutine runs on the event loop, so we ARE on the loop thread.
394
+ assert TuiUI._on_app_thread() is True
395
+
396
+ # Inline path (details present) — the exact shape of the plan-approval /
397
+ # edit-permission prompt. Degrades to cancel_value.
398
+ inline = ui.ask_choice(
399
+ ["Yes", "No"],
400
+ title="Approve?",
401
+ default=0,
402
+ cancel_value=99,
403
+ details=Panel("preview"),
404
+ )
405
+ assert inline == 99
406
+
407
+ # Modal path (no details) — degrades to None.
408
+ modal = ui.ask_choice(["Yes", "No"], title="Pick", default=0, cancel_value=None)
409
+ assert modal is None
410
+
411
+ # confirm() maps the degraded None back to the caller's default.
412
+ assert ui.confirm("Proceed?", default=True) is True
413
+ assert ui.confirm("Proceed?", default=False) is False
414
+
415
+
335
416
  @pytest.mark.asyncio
336
417
  async def test_tui_confirm_from_worker_returns_bool():
337
418
  from aru.tui.app import AruApp
@@ -356,3 +437,67 @@ async def test_tui_confirm_from_worker_returns_bool():
356
437
  await pilot.press("y")
357
438
  await asyncio.wait_for(worker_task, timeout=5.0)
358
439
  assert result_holder["answer"] is True
440
+
441
+
442
+ @pytest.mark.asyncio
443
+ async def test_memory_clear_slash_shows_confirm_modal_and_clears():
444
+ """Regression: ``/memory clear`` in the TUI must actually show the
445
+ ConfirmModal (and clear on "yes"), not silently degrade.
446
+
447
+ The slash bridge used to run handlers inline on the event-loop thread,
448
+ so ``handle_memory_command`` → ``ui.confirm`` → ``call_from_thread``
449
+ raised / degraded to the cancel default and the modal never appeared.
450
+ The bridge now hops the handler to a worker thread (``_run_bridged_slash``
451
+ → ``asyncio.to_thread``), so the modal is dispatched correctly and the
452
+ user's answer is honoured.
453
+ """
454
+ from unittest.mock import patch
455
+
456
+ from aru.runtime import init_ctx, set_ctx
457
+ from aru.session import Session
458
+ from aru.tui.app import AruApp
459
+ from aru.tui.screens import ConfirmModal
460
+ from aru.tui.ui import TuiUI
461
+
462
+ ctx = init_ctx()
463
+ session = Session()
464
+ session.project_root = "/tmp/aru-memory-clear-test"
465
+ app = AruApp(ctx=ctx, session=session)
466
+ ctx.tui_app = app
467
+
468
+ cleared: dict = {}
469
+
470
+ def _fake_clear(project_root, *args, **kwargs):
471
+ cleared["root"] = project_root
472
+ return 3
473
+
474
+ async with app.run_test() as pilot:
475
+ await pilot.pause()
476
+ ctx.ui = TuiUI(app)
477
+ set_ctx(ctx) # ensure the worker task + its to_thread child inherit ctx
478
+ # Patch the destructive clear so the test never touches real memory;
479
+ # the patch stays active across the awaits below, covering the
480
+ # handler's execution on the worker thread.
481
+ with patch("aru.memory.store.clear_memory", _fake_clear):
482
+ app._run_bridged_slash("memory", "clear")
483
+ # The crux of the fix: the modal actually appears. Before the
484
+ # fix the handler ran on the loop thread and degraded silently.
485
+ for _ in range(60):
486
+ await pilot.pause(0.05)
487
+ if app.screen_stack and isinstance(app.screen, ConfirmModal):
488
+ break
489
+ assert isinstance(app.screen, ConfirmModal), (
490
+ "ConfirmModal never appeared — /memory clear degraded instead "
491
+ "of prompting (handler ran on the event-loop thread)"
492
+ )
493
+ # Answer "yes" and let the worker finish + run the (patched) clear.
494
+ await pilot.press("y")
495
+ for _ in range(60):
496
+ await pilot.pause(0.05)
497
+ if "root" in cleared:
498
+ break
499
+
500
+ assert "root" in cleared, (
501
+ "clear_memory was never called — the 'yes' answer did not propagate "
502
+ "back through the worker to the handler"
503
+ )
@@ -1 +0,0 @@
1
- __version__ = "0.55.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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes