aru-code 0.46.0__tar.gz → 0.47.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 (188) hide show
  1. {aru_code-0.46.0/aru_code.egg-info → aru_code-0.47.0}/PKG-INFO +1 -1
  2. aru_code-0.47.0/aru/__init__.py +1 -0
  3. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/app.py +137 -0
  4. {aru_code-0.46.0 → aru_code-0.47.0/aru_code.egg-info}/PKG-INFO +1 -1
  5. {aru_code-0.46.0 → aru_code-0.47.0}/aru_code.egg-info/SOURCES.txt +1 -0
  6. {aru_code-0.46.0 → aru_code-0.47.0}/pyproject.toml +1 -1
  7. aru_code-0.47.0/tests/test_tui_shell_bang.py +183 -0
  8. aru_code-0.46.0/aru/__init__.py +0 -1
  9. {aru_code-0.46.0 → aru_code-0.47.0}/LICENSE +0 -0
  10. {aru_code-0.46.0 → aru_code-0.47.0}/README.md +0 -0
  11. {aru_code-0.46.0 → aru_code-0.47.0}/aru/agent_factory.py +0 -0
  12. {aru_code-0.46.0 → aru_code-0.47.0}/aru/agents/__init__.py +0 -0
  13. {aru_code-0.46.0 → aru_code-0.47.0}/aru/agents/base.py +0 -0
  14. {aru_code-0.46.0 → aru_code-0.47.0}/aru/agents/catalog.py +0 -0
  15. {aru_code-0.46.0 → aru_code-0.47.0}/aru/agents/planner.py +0 -0
  16. {aru_code-0.46.0 → aru_code-0.47.0}/aru/cache_patch.py +0 -0
  17. {aru_code-0.46.0 → aru_code-0.47.0}/aru/checkpoints.py +0 -0
  18. {aru_code-0.46.0 → aru_code-0.47.0}/aru/cli.py +0 -0
  19. {aru_code-0.46.0 → aru_code-0.47.0}/aru/commands.py +0 -0
  20. {aru_code-0.46.0 → aru_code-0.47.0}/aru/completers.py +0 -0
  21. {aru_code-0.46.0 → aru_code-0.47.0}/aru/config.py +0 -0
  22. {aru_code-0.46.0 → aru_code-0.47.0}/aru/context.py +0 -0
  23. {aru_code-0.46.0 → aru_code-0.47.0}/aru/display.py +0 -0
  24. {aru_code-0.46.0 → aru_code-0.47.0}/aru/events.py +0 -0
  25. {aru_code-0.46.0 → aru_code-0.47.0}/aru/format/__init__.py +0 -0
  26. {aru_code-0.46.0 → aru_code-0.47.0}/aru/format/manager.py +0 -0
  27. {aru_code-0.46.0 → aru_code-0.47.0}/aru/format/runner.py +0 -0
  28. {aru_code-0.46.0 → aru_code-0.47.0}/aru/history_blocks.py +0 -0
  29. {aru_code-0.46.0 → aru_code-0.47.0}/aru/lsp/__init__.py +0 -0
  30. {aru_code-0.46.0 → aru_code-0.47.0}/aru/lsp/client.py +0 -0
  31. {aru_code-0.46.0 → aru_code-0.47.0}/aru/lsp/manager.py +0 -0
  32. {aru_code-0.46.0 → aru_code-0.47.0}/aru/lsp/protocol.py +0 -0
  33. {aru_code-0.46.0 → aru_code-0.47.0}/aru/memory/__init__.py +0 -0
  34. {aru_code-0.46.0 → aru_code-0.47.0}/aru/memory/extractor.py +0 -0
  35. {aru_code-0.46.0 → aru_code-0.47.0}/aru/memory/loader.py +0 -0
  36. {aru_code-0.46.0 → aru_code-0.47.0}/aru/memory/store.py +0 -0
  37. {aru_code-0.46.0 → aru_code-0.47.0}/aru/permissions.py +0 -0
  38. {aru_code-0.46.0 → aru_code-0.47.0}/aru/plugin_cache.py +0 -0
  39. {aru_code-0.46.0 → aru_code-0.47.0}/aru/plugins/__init__.py +0 -0
  40. {aru_code-0.46.0 → aru_code-0.47.0}/aru/plugins/custom_tools.py +0 -0
  41. {aru_code-0.46.0 → aru_code-0.47.0}/aru/plugins/hooks.py +0 -0
  42. {aru_code-0.46.0 → aru_code-0.47.0}/aru/plugins/manager.py +0 -0
  43. {aru_code-0.46.0 → aru_code-0.47.0}/aru/plugins/tool_api.py +0 -0
  44. {aru_code-0.46.0 → aru_code-0.47.0}/aru/providers.py +0 -0
  45. {aru_code-0.46.0 → aru_code-0.47.0}/aru/runner.py +0 -0
  46. {aru_code-0.46.0 → aru_code-0.47.0}/aru/runtime.py +0 -0
  47. {aru_code-0.46.0 → aru_code-0.47.0}/aru/select.py +0 -0
  48. {aru_code-0.46.0 → aru_code-0.47.0}/aru/session.py +0 -0
  49. {aru_code-0.46.0 → aru_code-0.47.0}/aru/sinks.py +0 -0
  50. {aru_code-0.46.0 → aru_code-0.47.0}/aru/streaming.py +0 -0
  51. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tool_policy.py +0 -0
  52. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/__init__.py +0 -0
  53. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/_diff.py +0 -0
  54. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/_shared.py +0 -0
  55. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/apply_patch.py +0 -0
  56. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/apply_patch_prompt.txt +0 -0
  57. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/ast_tools.py +0 -0
  58. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/codebase.py +0 -0
  59. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/delegate.py +0 -0
  60. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/delegate_prompt.txt +0 -0
  61. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/file_ops.py +0 -0
  62. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/gitignore.py +0 -0
  63. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/lsp.py +0 -0
  64. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/mcp_client.py +0 -0
  65. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/memory_tool.py +0 -0
  66. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/plan_mode.py +0 -0
  67. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/ranker.py +0 -0
  68. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/registry.py +0 -0
  69. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/search.py +0 -0
  70. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/shell.py +0 -0
  71. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/skill.py +0 -0
  72. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/tasklist.py +0 -0
  73. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/web.py +0 -0
  74. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tools/worktree.py +0 -0
  75. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/__init__.py +0 -0
  76. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/sanitize.py +0 -0
  77. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/screens/__init__.py +0 -0
  78. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/screens/choice.py +0 -0
  79. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/screens/confirm.py +0 -0
  80. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/screens/search.py +0 -0
  81. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/screens/text_input.py +0 -0
  82. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/sinks.py +0 -0
  83. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/slash_bridge.py +0 -0
  84. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/ui.py +0 -0
  85. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/__init__.py +0 -0
  86. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/chat.py +0 -0
  87. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/completer.py +0 -0
  88. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/context_pane.py +0 -0
  89. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/header.py +0 -0
  90. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/inline_choice.py +0 -0
  91. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/loaded_pane.py +0 -0
  92. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/status.py +0 -0
  93. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/thinking.py +0 -0
  94. {aru_code-0.46.0 → aru_code-0.47.0}/aru/tui/widgets/tools.py +0 -0
  95. {aru_code-0.46.0 → aru_code-0.47.0}/aru/ui.py +0 -0
  96. {aru_code-0.46.0 → aru_code-0.47.0}/aru_code.egg-info/dependency_links.txt +0 -0
  97. {aru_code-0.46.0 → aru_code-0.47.0}/aru_code.egg-info/entry_points.txt +0 -0
  98. {aru_code-0.46.0 → aru_code-0.47.0}/aru_code.egg-info/requires.txt +0 -0
  99. {aru_code-0.46.0 → aru_code-0.47.0}/aru_code.egg-info/top_level.txt +0 -0
  100. {aru_code-0.46.0 → aru_code-0.47.0}/setup.cfg +0 -0
  101. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_agents_base.py +0 -0
  102. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_agents_md_coverage.py +0 -0
  103. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_apply_patch.py +0 -0
  104. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_async_tool_permission.py +0 -0
  105. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cache_patch_metrics.py +0 -0
  106. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cache_patch_stop_reason.py +0 -0
  107. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_catalog.py +0 -0
  108. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_chat_scrollable.py +0 -0
  109. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_checkpoints.py +0 -0
  110. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cli.py +0 -0
  111. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cli_advanced.py +0 -0
  112. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cli_base.py +0 -0
  113. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cli_completers.py +0 -0
  114. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cli_new.py +0 -0
  115. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cli_run_cli.py +0 -0
  116. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cli_session.py +0 -0
  117. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cli_shell.py +0 -0
  118. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_codebase.py +0 -0
  119. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_confabulation_regression.py +0 -0
  120. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_config.py +0 -0
  121. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_context.py +0 -0
  122. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_context_pane.py +0 -0
  123. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_cwd_awareness.py +0 -0
  124. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_delegate.py +0 -0
  125. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_events_backward_compat.py +0 -0
  126. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_events_schema.py +0 -0
  127. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_fork_ctx_concurrency.py +0 -0
  128. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_format.py +0 -0
  129. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_gitignore.py +0 -0
  130. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_guardrails_scenarios.py +0 -0
  131. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_invoke_skill.py +0 -0
  132. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_invoked_skills.py +0 -0
  133. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_loaded_pane_path.py +0 -0
  134. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_lsp.py +0 -0
  135. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_lsp_rename.py +0 -0
  136. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_main.py +0 -0
  137. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_markdown_to_text.py +0 -0
  138. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_mcp_client.py +0 -0
  139. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_mcp_health.py +0 -0
  140. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_memory.py +0 -0
  141. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_memory_tool.py +0 -0
  142. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_microcompact.py +0 -0
  143. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_permissions.py +0 -0
  144. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_plan_mode_refactor.py +0 -0
  145. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_plugin_cache.py +0 -0
  146. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_plugin_errors.py +0 -0
  147. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_plugin_hooks_v2.py +0 -0
  148. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_plugins.py +0 -0
  149. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_providers.py +0 -0
  150. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_ranker.py +0 -0
  151. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_reasoning.py +0 -0
  152. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_runner_interrupt.py +0 -0
  153. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_runner_recovery.py +0 -0
  154. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_runtime.py +0 -0
  155. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_select.py +0 -0
  156. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_skill_disallowed_tools.py +0 -0
  157. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_status_breakdown.py +0 -0
  158. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_status_cost.py +0 -0
  159. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_streaming_sink.py +0 -0
  160. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tasklist.py +0 -0
  161. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_thread_tool_timeout.py +0 -0
  162. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tool_policy.py +0 -0
  163. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_truncation_marker.py +0 -0
  164. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_app_boot.py +0 -0
  165. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_bindings.py +0 -0
  166. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_bus_flow.py +0 -0
  167. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_chat.py +0 -0
  168. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_chat_adversarial.py +0 -0
  169. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_completer.py +0 -0
  170. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_completer_dynamic.py +0 -0
  171. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_copy.py +0 -0
  172. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_input_behaviour.py +0 -0
  173. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_layer12_recovery.py +0 -0
  174. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_layer13_recovery.py +0 -0
  175. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_mention_expand.py +0 -0
  176. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_modals.py +0 -0
  177. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_mode_cycle.py +0 -0
  178. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_native_selection.py +0 -0
  179. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_permission_flow.py +0 -0
  180. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_plan_task_render.py +0 -0
  181. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_sidebar_toggle.py +0 -0
  182. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_slash_bridge.py +0 -0
  183. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_snapshot_smoke.py +0 -0
  184. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_thinking_and_boot.py +0 -0
  185. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_tui_widgets_visual.py +0 -0
  186. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_ui_adapter.py +0 -0
  187. {aru_code-0.46.0 → aru_code-0.47.0}/tests/test_worktree.py +0 -0
  188. {aru_code-0.46.0 → aru_code-0.47.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.46.0
3
+ Version: 0.47.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.47.0"
@@ -647,6 +647,27 @@ class AruApp(App):
647
647
  if text.startswith("/") and self._maybe_run_local_slash(text):
648
648
  return
649
649
 
650
+ # Shell escape: ``! <command>`` runs the command locally in the
651
+ # session cwd and streams output into the chat. Mirrors the REPL
652
+ # path in ``cli.py`` so users can do quick ``! git status`` or
653
+ # ``! ls`` without a round-trip to the agent. The leading ``!``
654
+ # must be followed by whitespace so plain text starting with ``!``
655
+ # (rare but possible) still reaches the agent.
656
+ if text.startswith("!"):
657
+ cmd = text[1:].lstrip()
658
+ if not cmd:
659
+ self.query_one(ChatPane).add_system_message(
660
+ "Usage: ! <command>"
661
+ )
662
+ return
663
+ if self._busy:
664
+ self.query_one(ChatPane).add_system_message(
665
+ "Busy — wait for the current task to finish."
666
+ )
667
+ return
668
+ self._dispatch_shell_command(cmd)
669
+ return
670
+
650
671
  if self._busy:
651
672
  self.query_one(ChatPane).add_system_message(
652
673
  "Agent is busy — wait for the current turn to finish."
@@ -963,6 +984,7 @@ class AruApp(App):
963
984
  " /clear clear chat pane",
964
985
  " /plan toggle plan mode",
965
986
  " /quit /exit save session and exit",
987
+ " ! <command> run a shell command (output streams to chat)",
966
988
  "",
967
989
  "Shortcuts:",
968
990
  " Ctrl+Q quit",
@@ -1128,6 +1150,121 @@ class AruApp(App):
1128
1150
  # without waiting for the periodic Layer 10 tick.
1129
1151
  self._reenable_mouse_tracking()
1130
1152
 
1153
+ # ── Shell escape (``! <command>``) ───────────────────────────────
1154
+
1155
+ def _dispatch_shell_command(self, command: str) -> None:
1156
+ """Run ``command`` in the session cwd and stream output to chat.
1157
+
1158
+ Parity with the REPL's ``! <cmd>`` path in ``cli.py``: we render
1159
+ a syntax-highlighted header, run the command via the system
1160
+ shell, then push stdout/stderr (interleaved) into a single
1161
+ system message that grows as lines arrive. The exit code is
1162
+ appended on completion so the user can tell success from
1163
+ failure.
1164
+
1165
+ Output is NOT persisted to ``session.history`` — the agent never
1166
+ sees ``!`` shell runs (it has its own ``bash`` tool). This is a
1167
+ user convenience, not part of the conversation.
1168
+ """
1169
+ chat = self.query_one(ChatPane)
1170
+ try:
1171
+ from rich.panel import Panel
1172
+ from rich.syntax import Syntax
1173
+ chat.add_renderable(Panel(
1174
+ Syntax(command, "bash", theme="monokai"),
1175
+ title="[bold]Shell[/bold]",
1176
+ border_style="dim",
1177
+ expand=False,
1178
+ ))
1179
+ except Exception:
1180
+ chat.add_system_message(f"$ {command}")
1181
+
1182
+ from aru.tui.widgets.chat import ChatMessageWidget
1183
+ live = ChatMessageWidget(role="system", initial="")
1184
+ chat.mount(live)
1185
+ self._busy = True
1186
+ try:
1187
+ self.query_one(ThinkingIndicator).busy = True
1188
+ except Exception:
1189
+ pass
1190
+ self.run_worker(
1191
+ self._run_shell_command(command, live),
1192
+ name="shell-cmd",
1193
+ exclusive=False,
1194
+ group="shell",
1195
+ )
1196
+
1197
+ async def _run_shell_command(
1198
+ self, command: str, live: "ChatMessageWidget"
1199
+ ) -> None:
1200
+ """Spawn ``command`` and stream output into ``live`` line by line."""
1201
+ import asyncio
1202
+
1203
+ try:
1204
+ from aru.runtime import get_cwd
1205
+ cwd = get_cwd()
1206
+ except Exception:
1207
+ import os
1208
+ cwd = os.getcwd()
1209
+
1210
+ try:
1211
+ proc = await asyncio.create_subprocess_shell(
1212
+ command,
1213
+ stdout=asyncio.subprocess.PIPE,
1214
+ stderr=asyncio.subprocess.STDOUT,
1215
+ cwd=cwd,
1216
+ )
1217
+ except Exception as exc:
1218
+ live.buffer = f"[shell error] {type(exc).__name__}: {exc}"
1219
+ self._busy = False
1220
+ try:
1221
+ self.query_one(ThinkingIndicator).busy = False
1222
+ except Exception:
1223
+ pass
1224
+ return
1225
+
1226
+ assert proc.stdout is not None
1227
+ buffer_lines: list[str] = []
1228
+ try:
1229
+ while True:
1230
+ raw = await proc.stdout.readline()
1231
+ if not raw:
1232
+ break
1233
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
1234
+ buffer_lines.append(line)
1235
+ # Cap displayed buffer so a runaway command doesn't grow
1236
+ # the widget until the chat pane stalls. Mirrors the
1237
+ # ``bash`` tool's 10K-char output truncation.
1238
+ joined = "\n".join(buffer_lines)
1239
+ if len(joined) > 10_000:
1240
+ head = joined[:10_000]
1241
+ live.buffer = head + "\n... (truncated, still running)"
1242
+ else:
1243
+ live.buffer = joined
1244
+ await proc.wait()
1245
+ except asyncio.CancelledError:
1246
+ try:
1247
+ proc.kill()
1248
+ except Exception:
1249
+ pass
1250
+ live.buffer = (live.buffer or "") + "\n[interrupted]"
1251
+ raise
1252
+ except Exception as exc:
1253
+ live.buffer = (live.buffer or "") + (
1254
+ f"\n[shell error] {type(exc).__name__}: {exc}"
1255
+ )
1256
+ finally:
1257
+ rc = proc.returncode if proc.returncode is not None else "?"
1258
+ tail = f"\n[exit {rc}]"
1259
+ current = live.buffer or ""
1260
+ if not current.endswith(tail):
1261
+ live.buffer = current + tail
1262
+ self._busy = False
1263
+ try:
1264
+ self.query_one(ThinkingIndicator).busy = False
1265
+ except Exception:
1266
+ pass
1267
+
1131
1268
  # Layer 14 — full set of DEC private modes that ``WindowsDriver
1132
1269
  # .start_application_mode`` enables at boot, minus alt-screen
1133
1270
  # (``?1049``, not idempotent — would save/restore the display
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.46.0
3
+ Version: 0.47.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
@@ -174,6 +174,7 @@ tests/test_tui_mode_cycle.py
174
174
  tests/test_tui_native_selection.py
175
175
  tests/test_tui_permission_flow.py
176
176
  tests/test_tui_plan_task_render.py
177
+ tests/test_tui_shell_bang.py
177
178
  tests/test_tui_sidebar_toggle.py
178
179
  tests/test_tui_slash_bridge.py
179
180
  tests/test_tui_snapshot_smoke.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.46.0"
7
+ version = "0.47.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,183 @@
1
+ """Tests for the ``! <command>`` shell escape in the TUI.
2
+
3
+ The TUI mirrors the REPL's ``! cmd`` path: typing ``! echo hi`` runs
4
+ ``echo hi`` locally (in the session cwd), streams output into the chat
5
+ pane, and reports the exit code — without invoking the agent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ import pytest
13
+
14
+ pytest.importorskip("textual")
15
+
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_bang_dispatches_shell_not_agent():
19
+ """``! cmd`` should run via _dispatch_shell_command, NOT _dispatch_user_turn."""
20
+ from aru.tui.app import AruApp
21
+ from textual.widgets import Input
22
+
23
+ captured: dict = {}
24
+
25
+ class _Probe(AruApp):
26
+ def _dispatch_user_turn(self, text: str) -> None: # type: ignore[override]
27
+ captured["agent_text"] = text
28
+
29
+ def _dispatch_shell_command(self, cmd: str) -> None: # type: ignore[override]
30
+ captured["shell_cmd"] = cmd
31
+
32
+ app = _Probe()
33
+ async with app.run_test() as pilot:
34
+ await pilot.pause()
35
+ inp = app.query_one(Input)
36
+ inp.post_message(Input.Submitted(inp, value="! echo hi"))
37
+ await pilot.pause()
38
+
39
+ assert captured.get("shell_cmd") == "echo hi"
40
+ assert "agent_text" not in captured
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_bang_empty_command_warns():
45
+ """``! `` alone should show a usage message and not dispatch anything."""
46
+ from aru.tui.app import AruApp
47
+ from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
48
+ from textual.widgets import Input
49
+
50
+ captured: dict = {}
51
+
52
+ class _Probe(AruApp):
53
+ def _dispatch_user_turn(self, text: str) -> None: # type: ignore[override]
54
+ captured["agent_text"] = text
55
+
56
+ def _dispatch_shell_command(self, cmd: str) -> None: # type: ignore[override]
57
+ captured["shell_cmd"] = cmd
58
+
59
+ app = _Probe()
60
+ async with app.run_test() as pilot:
61
+ await pilot.pause()
62
+ inp = app.query_one(Input)
63
+ inp.post_message(Input.Submitted(inp, value="! "))
64
+ await pilot.pause()
65
+ chat = app.query_one(ChatPane)
66
+ msgs = list(chat.query(ChatMessageWidget))
67
+ joined = " ".join(m.buffer for m in msgs)
68
+
69
+ assert "Usage:" in joined
70
+ assert "shell_cmd" not in captured
71
+ assert "agent_text" not in captured
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_bang_busy_blocks_dispatch():
76
+ """If the app is already busy, ``! cmd`` should refuse to start."""
77
+ from aru.tui.app import AruApp
78
+ from textual.widgets import Input
79
+
80
+ captured: dict = {}
81
+
82
+ class _Probe(AruApp):
83
+ def _dispatch_shell_command(self, cmd: str) -> None: # type: ignore[override]
84
+ captured["shell_cmd"] = cmd
85
+
86
+ app = _Probe()
87
+ async with app.run_test() as pilot:
88
+ await pilot.pause()
89
+ app._busy = True
90
+ inp = app.query_one(Input)
91
+ inp.post_message(Input.Submitted(inp, value="! echo hi"))
92
+ await pilot.pause()
93
+
94
+ assert "shell_cmd" not in captured
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_bang_runs_real_command_and_streams_output():
99
+ """End-to-end: a real shell command's output reaches the chat pane."""
100
+ from aru.tui.app import AruApp
101
+ from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
102
+ from textual.widgets import Input
103
+
104
+ # Pick a command that works on both Windows and POSIX. ``python -c``
105
+ # avoids shell-specific syntax (echo behaves differently between
106
+ # cmd.exe and bash) and forces a known output line.
107
+ py = sys.executable.replace("\\", "/")
108
+ command = f'{py} -c "print(\'aru-shell-marker\')"'
109
+
110
+ app = AruApp()
111
+ async with app.run_test() as pilot:
112
+ await pilot.pause()
113
+ inp = app.query_one(Input)
114
+ inp.post_message(Input.Submitted(inp, value=f"! {command}"))
115
+ # Wait for the worker to finish — _busy flips back to False once
116
+ # the subprocess exits and the finally block runs.
117
+ for _ in range(200):
118
+ await pilot.pause(0.05)
119
+ if not app._busy:
120
+ break
121
+ chat = app.query_one(ChatPane)
122
+ msgs = list(chat.query(ChatMessageWidget))
123
+ joined = "\n".join(m.buffer for m in msgs)
124
+
125
+ assert "aru-shell-marker" in joined
126
+ assert "[exit 0]" in joined
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_bang_does_not_persist_to_session_history():
131
+ """Shell runs are local — they must not land in session.history.
132
+
133
+ Otherwise the agent would see ``! ls``-style turns on the next
134
+ prompt and try to reason about them as if the user had said them.
135
+ """
136
+ from aru.tui.app import AruApp
137
+ from aru.session import Session
138
+ from textual.widgets import Input
139
+
140
+ app = AruApp()
141
+ async with app.run_test() as pilot:
142
+ await pilot.pause()
143
+ app.session = Session(session_id="test-shell-no-history")
144
+ inp = app.query_one(Input)
145
+ inp.post_message(Input.Submitted(inp, value="! echo hi"))
146
+ # Don't even need to wait for the worker — persistence (or lack
147
+ # thereof) is decided synchronously during dispatch.
148
+ await pilot.pause()
149
+ # Stop the worker promptly so the test exits cleanly.
150
+ try:
151
+ for w in list(app.workers):
152
+ w.cancel()
153
+ except Exception:
154
+ pass
155
+
156
+ user_msgs = [m for m in app.session.history if m.get("role") == "user"]
157
+ assert user_msgs == []
158
+
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_bang_failing_command_reports_nonzero_exit():
162
+ """A command that exits non-zero should still surface an exit-code line."""
163
+ from aru.tui.app import AruApp
164
+ from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
165
+ from textual.widgets import Input
166
+
167
+ py = sys.executable.replace("\\", "/")
168
+ command = f'{py} -c "import sys; sys.exit(7)"'
169
+
170
+ app = AruApp()
171
+ async with app.run_test() as pilot:
172
+ await pilot.pause()
173
+ inp = app.query_one(Input)
174
+ inp.post_message(Input.Submitted(inp, value=f"! {command}"))
175
+ for _ in range(200):
176
+ await pilot.pause(0.05)
177
+ if not app._busy:
178
+ break
179
+ chat = app.query_one(ChatPane)
180
+ msgs = list(chat.query(ChatMessageWidget))
181
+ joined = "\n".join(m.buffer for m in msgs)
182
+
183
+ assert "[exit 7]" in joined
@@ -1 +0,0 @@
1
- __version__ = "0.46.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
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