aru-code 0.46.0__tar.gz → 0.48.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 (192) hide show
  1. {aru_code-0.46.0/aru_code.egg-info → aru_code-0.48.0}/PKG-INFO +1 -1
  2. aru_code-0.48.0/aru/__init__.py +1 -0
  3. {aru_code-0.46.0 → aru_code-0.48.0}/aru/runner.py +8 -2
  4. {aru_code-0.46.0 → aru_code-0.48.0}/aru/session.py +13 -1
  5. {aru_code-0.46.0 → aru_code-0.48.0}/aru/sinks.py +5 -0
  6. {aru_code-0.46.0 → aru_code-0.48.0}/aru/streaming.py +8 -0
  7. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/app.py +214 -8
  8. aru_code-0.48.0/aru/tui/log_bridge.py +128 -0
  9. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/sinks.py +10 -0
  10. {aru_code-0.46.0 → aru_code-0.48.0/aru_code.egg-info}/PKG-INFO +1 -1
  11. {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/SOURCES.txt +5 -0
  12. {aru_code-0.46.0 → aru_code-0.48.0}/pyproject.toml +1 -1
  13. aru_code-0.48.0/tests/test_session_free_cost.py +59 -0
  14. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_streaming_sink.py +3 -0
  15. aru_code-0.48.0/tests/test_tui_error_display.py +205 -0
  16. aru_code-0.48.0/tests/test_tui_shell_bang.py +183 -0
  17. aru_code-0.48.0/tests/test_tui_slash_model.py +141 -0
  18. aru_code-0.46.0/aru/__init__.py +0 -1
  19. {aru_code-0.46.0 → aru_code-0.48.0}/LICENSE +0 -0
  20. {aru_code-0.46.0 → aru_code-0.48.0}/README.md +0 -0
  21. {aru_code-0.46.0 → aru_code-0.48.0}/aru/agent_factory.py +0 -0
  22. {aru_code-0.46.0 → aru_code-0.48.0}/aru/agents/__init__.py +0 -0
  23. {aru_code-0.46.0 → aru_code-0.48.0}/aru/agents/base.py +0 -0
  24. {aru_code-0.46.0 → aru_code-0.48.0}/aru/agents/catalog.py +0 -0
  25. {aru_code-0.46.0 → aru_code-0.48.0}/aru/agents/planner.py +0 -0
  26. {aru_code-0.46.0 → aru_code-0.48.0}/aru/cache_patch.py +0 -0
  27. {aru_code-0.46.0 → aru_code-0.48.0}/aru/checkpoints.py +0 -0
  28. {aru_code-0.46.0 → aru_code-0.48.0}/aru/cli.py +0 -0
  29. {aru_code-0.46.0 → aru_code-0.48.0}/aru/commands.py +0 -0
  30. {aru_code-0.46.0 → aru_code-0.48.0}/aru/completers.py +0 -0
  31. {aru_code-0.46.0 → aru_code-0.48.0}/aru/config.py +0 -0
  32. {aru_code-0.46.0 → aru_code-0.48.0}/aru/context.py +0 -0
  33. {aru_code-0.46.0 → aru_code-0.48.0}/aru/display.py +0 -0
  34. {aru_code-0.46.0 → aru_code-0.48.0}/aru/events.py +0 -0
  35. {aru_code-0.46.0 → aru_code-0.48.0}/aru/format/__init__.py +0 -0
  36. {aru_code-0.46.0 → aru_code-0.48.0}/aru/format/manager.py +0 -0
  37. {aru_code-0.46.0 → aru_code-0.48.0}/aru/format/runner.py +0 -0
  38. {aru_code-0.46.0 → aru_code-0.48.0}/aru/history_blocks.py +0 -0
  39. {aru_code-0.46.0 → aru_code-0.48.0}/aru/lsp/__init__.py +0 -0
  40. {aru_code-0.46.0 → aru_code-0.48.0}/aru/lsp/client.py +0 -0
  41. {aru_code-0.46.0 → aru_code-0.48.0}/aru/lsp/manager.py +0 -0
  42. {aru_code-0.46.0 → aru_code-0.48.0}/aru/lsp/protocol.py +0 -0
  43. {aru_code-0.46.0 → aru_code-0.48.0}/aru/memory/__init__.py +0 -0
  44. {aru_code-0.46.0 → aru_code-0.48.0}/aru/memory/extractor.py +0 -0
  45. {aru_code-0.46.0 → aru_code-0.48.0}/aru/memory/loader.py +0 -0
  46. {aru_code-0.46.0 → aru_code-0.48.0}/aru/memory/store.py +0 -0
  47. {aru_code-0.46.0 → aru_code-0.48.0}/aru/permissions.py +0 -0
  48. {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugin_cache.py +0 -0
  49. {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/__init__.py +0 -0
  50. {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/custom_tools.py +0 -0
  51. {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/hooks.py +0 -0
  52. {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/manager.py +0 -0
  53. {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/tool_api.py +0 -0
  54. {aru_code-0.46.0 → aru_code-0.48.0}/aru/providers.py +0 -0
  55. {aru_code-0.46.0 → aru_code-0.48.0}/aru/runtime.py +0 -0
  56. {aru_code-0.46.0 → aru_code-0.48.0}/aru/select.py +0 -0
  57. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tool_policy.py +0 -0
  58. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/__init__.py +0 -0
  59. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/_diff.py +0 -0
  60. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/_shared.py +0 -0
  61. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/apply_patch.py +0 -0
  62. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/apply_patch_prompt.txt +0 -0
  63. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/ast_tools.py +0 -0
  64. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/codebase.py +0 -0
  65. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/delegate.py +0 -0
  66. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/delegate_prompt.txt +0 -0
  67. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/file_ops.py +0 -0
  68. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/gitignore.py +0 -0
  69. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/lsp.py +0 -0
  70. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/mcp_client.py +0 -0
  71. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/memory_tool.py +0 -0
  72. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/plan_mode.py +0 -0
  73. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/ranker.py +0 -0
  74. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/registry.py +0 -0
  75. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/search.py +0 -0
  76. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/shell.py +0 -0
  77. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/skill.py +0 -0
  78. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/tasklist.py +0 -0
  79. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/web.py +0 -0
  80. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/worktree.py +0 -0
  81. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/__init__.py +0 -0
  82. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/sanitize.py +0 -0
  83. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/__init__.py +0 -0
  84. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/choice.py +0 -0
  85. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/confirm.py +0 -0
  86. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/search.py +0 -0
  87. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/text_input.py +0 -0
  88. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/slash_bridge.py +0 -0
  89. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/ui.py +0 -0
  90. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/__init__.py +0 -0
  91. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/chat.py +0 -0
  92. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/completer.py +0 -0
  93. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/context_pane.py +0 -0
  94. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/header.py +0 -0
  95. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/inline_choice.py +0 -0
  96. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/loaded_pane.py +0 -0
  97. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/status.py +0 -0
  98. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/thinking.py +0 -0
  99. {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/tools.py +0 -0
  100. {aru_code-0.46.0 → aru_code-0.48.0}/aru/ui.py +0 -0
  101. {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/dependency_links.txt +0 -0
  102. {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/entry_points.txt +0 -0
  103. {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/requires.txt +0 -0
  104. {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/top_level.txt +0 -0
  105. {aru_code-0.46.0 → aru_code-0.48.0}/setup.cfg +0 -0
  106. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_agents_base.py +0 -0
  107. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_agents_md_coverage.py +0 -0
  108. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_apply_patch.py +0 -0
  109. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_async_tool_permission.py +0 -0
  110. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cache_patch_metrics.py +0 -0
  111. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cache_patch_stop_reason.py +0 -0
  112. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_catalog.py +0 -0
  113. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_chat_scrollable.py +0 -0
  114. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_checkpoints.py +0 -0
  115. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli.py +0 -0
  116. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_advanced.py +0 -0
  117. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_base.py +0 -0
  118. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_completers.py +0 -0
  119. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_new.py +0 -0
  120. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_run_cli.py +0 -0
  121. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_session.py +0 -0
  122. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_shell.py +0 -0
  123. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_codebase.py +0 -0
  124. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_confabulation_regression.py +0 -0
  125. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_config.py +0 -0
  126. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_context.py +0 -0
  127. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_context_pane.py +0 -0
  128. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cwd_awareness.py +0 -0
  129. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_delegate.py +0 -0
  130. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_events_backward_compat.py +0 -0
  131. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_events_schema.py +0 -0
  132. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_fork_ctx_concurrency.py +0 -0
  133. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_format.py +0 -0
  134. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_gitignore.py +0 -0
  135. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_guardrails_scenarios.py +0 -0
  136. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_invoke_skill.py +0 -0
  137. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_invoked_skills.py +0 -0
  138. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_loaded_pane_path.py +0 -0
  139. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_lsp.py +0 -0
  140. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_lsp_rename.py +0 -0
  141. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_main.py +0 -0
  142. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_markdown_to_text.py +0 -0
  143. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_mcp_client.py +0 -0
  144. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_mcp_health.py +0 -0
  145. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_memory.py +0 -0
  146. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_memory_tool.py +0 -0
  147. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_microcompact.py +0 -0
  148. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_permissions.py +0 -0
  149. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plan_mode_refactor.py +0 -0
  150. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plugin_cache.py +0 -0
  151. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plugin_errors.py +0 -0
  152. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plugin_hooks_v2.py +0 -0
  153. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plugins.py +0 -0
  154. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_providers.py +0 -0
  155. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_ranker.py +0 -0
  156. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_reasoning.py +0 -0
  157. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_runner_interrupt.py +0 -0
  158. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_runner_recovery.py +0 -0
  159. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_runtime.py +0 -0
  160. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_select.py +0 -0
  161. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_skill_disallowed_tools.py +0 -0
  162. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_status_breakdown.py +0 -0
  163. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_status_cost.py +0 -0
  164. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tasklist.py +0 -0
  165. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_thread_tool_timeout.py +0 -0
  166. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tool_policy.py +0 -0
  167. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_truncation_marker.py +0 -0
  168. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_app_boot.py +0 -0
  169. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_bindings.py +0 -0
  170. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_bus_flow.py +0 -0
  171. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_chat.py +0 -0
  172. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_chat_adversarial.py +0 -0
  173. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_completer.py +0 -0
  174. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_completer_dynamic.py +0 -0
  175. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_copy.py +0 -0
  176. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_input_behaviour.py +0 -0
  177. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_layer12_recovery.py +0 -0
  178. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_layer13_recovery.py +0 -0
  179. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_mention_expand.py +0 -0
  180. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_modals.py +0 -0
  181. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_mode_cycle.py +0 -0
  182. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_native_selection.py +0 -0
  183. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_permission_flow.py +0 -0
  184. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_plan_task_render.py +0 -0
  185. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_sidebar_toggle.py +0 -0
  186. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_slash_bridge.py +0 -0
  187. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_snapshot_smoke.py +0 -0
  188. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_thinking_and_boot.py +0 -0
  189. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_widgets_visual.py +0 -0
  190. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_ui_adapter.py +0 -0
  191. {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_worktree.py +0 -0
  192. {aru_code-0.46.0 → aru_code-0.48.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.48.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.48.0"
@@ -695,8 +695,14 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
695
695
  sink.exit()
696
696
  except Exception:
697
697
  pass
698
- from rich.markup import escape
699
- console.print(f"[red]Error: {escape(str(e))}[/red]")
698
+ # Route via the sink so the message reaches the right surface:
699
+ # REPL → Rich console; TUI → ChatPane system message (Textual
700
+ # hijacks stderr/stdout, so ``console.print`` would be invisible).
701
+ try:
702
+ sink.on_error(str(e))
703
+ except Exception:
704
+ from rich.markup import escape
705
+ console.print(f"[red]Error: {escape(str(e))}[/red]")
700
706
 
701
707
  # Final guard: if a plan is active and the agent ended its turn with
702
708
  # pending steps (without stalling), mark the session so the next turn's
@@ -565,7 +565,19 @@ class Session:
565
565
  self._live_cache_write_added = 0
566
566
 
567
567
  def _get_pricing(self) -> tuple[float, float, float, float]:
568
- """Get per-million-token pricing for the current model."""
568
+ """Get per-million-token pricing for the current model.
569
+
570
+ Free models (e.g. OpenRouter `:free` variants like
571
+ `openrouter/minimax/minimax-m2.5:free`) report no cost. Detected by
572
+ the literal token "free" in the model ref or id — covers the `:free`
573
+ suffix convention plus any future provider that adopts the same
574
+ naming. None of the major paid models contain "free" in their id,
575
+ so false positives are negligible.
576
+ """
577
+ ref = (self.model_ref or "").lower()
578
+ mid = (self.model_id or "").lower()
579
+ if "free" in ref or "free" in mid:
580
+ return (0.0, 0.0, 0.0, 0.0)
569
581
  model_id = self.model_id
570
582
  # Try exact match, then prefix match, then fallback
571
583
  for prefix, pricing in MODEL_PRICING.items():
@@ -238,6 +238,11 @@ class RichLiveSink:
238
238
  else:
239
239
  target.print(message)
240
240
 
241
+ def on_error(self, message: str) -> None:
242
+ from rich.markup import escape
243
+ target = self._live.console if self._live is not None else self.console
244
+ target.print(f"[red]Error: {escape(message)}[/red]")
245
+
241
246
  def on_stream_finished(self, *, final_content: str) -> None:
242
247
  # Nothing to do here — runner flushes trailing markdown after exit()
243
248
  # using the accumulated content + display._flushed_len.
@@ -121,6 +121,14 @@ class StreamSink(Protocol):
121
121
  """Best-effort sideband user message (warnings etc.)."""
122
122
  ...
123
123
 
124
+ def on_error(self, message: str) -> None:
125
+ """Terminal error — runner caught an exception from the agent run.
126
+
127
+ REPL renders via Rich console; TUI must route to the ChatPane so
128
+ the user actually sees it (Textual hijacks stderr/stdout).
129
+ """
130
+ ...
131
+
124
132
  def on_stream_finished(self, *, final_content: str) -> None:
125
133
  """Run finished — sink may render any trailing markdown."""
126
134
  ...
@@ -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."
@@ -856,22 +877,76 @@ class AruApp(App):
856
877
  if session is None:
857
878
  self._push_chat("No session.", "model")
858
879
  return
880
+ from aru.providers import (
881
+ MODEL_ALIASES,
882
+ get_provider,
883
+ list_providers,
884
+ resolve_model_ref,
885
+ )
886
+
887
+ config = self.config
888
+ config_aliases = (getattr(config, "model_aliases", None) or {}) if config else {}
889
+
859
890
  body = body.strip()
860
891
  if not body:
861
- current = getattr(session, "model_ref", "?")
862
- self._push_chat(
863
- f"Current model: {current}\n"
864
- f"Usage: /model <provider/name> (e.g. /model anthropic/claude-sonnet-4-5)",
865
- "model",
866
- )
892
+ lines = [
893
+ f"Current model: {session.model_display} ({session.model_id})",
894
+ "",
895
+ ]
896
+ if config_aliases:
897
+ lines.append("Model aliases (aru.json):")
898
+ for alias, ref in config_aliases.items():
899
+ lines.append(f" {alias} → {ref}")
900
+ lines.append("")
901
+ lines.append("Built-in aliases:")
902
+ for alias, ref in MODEL_ALIASES.items():
903
+ lines.append(f" {alias} → {ref}")
904
+ lines.append("")
905
+ lines.append("Providers:")
906
+ for pkey, pconfig in list_providers().items():
907
+ dflt = pconfig.default_model or "—"
908
+ lines.append(f" {pkey} ({pconfig.name}) — default: {dflt}")
909
+ lines.append("")
910
+ lines.append("Usage: /model <provider/name> (e.g. /model anthropic/claude-sonnet-4-5, /model minimax)")
911
+ self._push_chat("\n".join(lines), "model")
867
912
  return
913
+
868
914
  try:
869
- session.model_ref = body
915
+ arg_lower = body.lower()
916
+ resolved_ref = config_aliases.get(arg_lower, arg_lower) if config_aliases else arg_lower
917
+ provider_key, _ = resolve_model_ref(resolved_ref)
918
+ if get_provider(provider_key) is None:
919
+ available = ", ".join(sorted(list_providers().keys()))
920
+ self._push_chat(
921
+ f"Unknown provider '{provider_key}'. Available: {available}",
922
+ "model",
923
+ )
924
+ return
925
+ # Normalize to the fully-qualified ref so model_display + create_model
926
+ # see the right provider/model pair.
927
+ session.model_ref = resolved_ref if "/" in resolved_ref else (
928
+ MODEL_ALIASES.get(resolved_ref, resolved_ref)
929
+ )
870
930
  if self.ctx is not None:
871
931
  self.ctx.model_id = session.model_id
932
+ small_ref = config_aliases.get("small")
933
+ if not small_ref:
934
+ sp_key, _ = resolve_model_ref(session.model_ref)
935
+ _small_defaults = {
936
+ "anthropic": "anthropic/claude-haiku-4-5",
937
+ "openai": "openai/gpt-4o-mini",
938
+ "groq": "groq/llama-3.1-8b-instant",
939
+ "deepseek": "deepseek/deepseek-chat",
940
+ "ollama": "ollama/llama3.1",
941
+ }
942
+ small_ref = _small_defaults.get(sp_key, session.model_ref)
943
+ self.ctx.small_model_ref = small_ref
872
944
  status = self.query_one(StatusPane)
873
945
  status._refresh_from_session()
874
- self._push_chat(f"Model switched to: {body}", "model")
946
+ self._push_chat(
947
+ f"Switched to {session.model_display} ({session.model_id})",
948
+ "model",
949
+ )
875
950
  except Exception as exc:
876
951
  self._push_chat(f"model switch failed: {exc}", "model")
877
952
 
@@ -963,6 +1038,7 @@ class AruApp(App):
963
1038
  " /clear clear chat pane",
964
1039
  " /plan toggle plan mode",
965
1040
  " /quit /exit save session and exit",
1041
+ " ! <command> run a shell command (output streams to chat)",
966
1042
  "",
967
1043
  "Shortcuts:",
968
1044
  " Ctrl+Q quit",
@@ -1128,6 +1204,121 @@ class AruApp(App):
1128
1204
  # without waiting for the periodic Layer 10 tick.
1129
1205
  self._reenable_mouse_tracking()
1130
1206
 
1207
+ # ── Shell escape (``! <command>``) ───────────────────────────────
1208
+
1209
+ def _dispatch_shell_command(self, command: str) -> None:
1210
+ """Run ``command`` in the session cwd and stream output to chat.
1211
+
1212
+ Parity with the REPL's ``! <cmd>`` path in ``cli.py``: we render
1213
+ a syntax-highlighted header, run the command via the system
1214
+ shell, then push stdout/stderr (interleaved) into a single
1215
+ system message that grows as lines arrive. The exit code is
1216
+ appended on completion so the user can tell success from
1217
+ failure.
1218
+
1219
+ Output is NOT persisted to ``session.history`` — the agent never
1220
+ sees ``!`` shell runs (it has its own ``bash`` tool). This is a
1221
+ user convenience, not part of the conversation.
1222
+ """
1223
+ chat = self.query_one(ChatPane)
1224
+ try:
1225
+ from rich.panel import Panel
1226
+ from rich.syntax import Syntax
1227
+ chat.add_renderable(Panel(
1228
+ Syntax(command, "bash", theme="monokai"),
1229
+ title="[bold]Shell[/bold]",
1230
+ border_style="dim",
1231
+ expand=False,
1232
+ ))
1233
+ except Exception:
1234
+ chat.add_system_message(f"$ {command}")
1235
+
1236
+ from aru.tui.widgets.chat import ChatMessageWidget
1237
+ live = ChatMessageWidget(role="system", initial="")
1238
+ chat.mount(live)
1239
+ self._busy = True
1240
+ try:
1241
+ self.query_one(ThinkingIndicator).busy = True
1242
+ except Exception:
1243
+ pass
1244
+ self.run_worker(
1245
+ self._run_shell_command(command, live),
1246
+ name="shell-cmd",
1247
+ exclusive=False,
1248
+ group="shell",
1249
+ )
1250
+
1251
+ async def _run_shell_command(
1252
+ self, command: str, live: "ChatMessageWidget"
1253
+ ) -> None:
1254
+ """Spawn ``command`` and stream output into ``live`` line by line."""
1255
+ import asyncio
1256
+
1257
+ try:
1258
+ from aru.runtime import get_cwd
1259
+ cwd = get_cwd()
1260
+ except Exception:
1261
+ import os
1262
+ cwd = os.getcwd()
1263
+
1264
+ try:
1265
+ proc = await asyncio.create_subprocess_shell(
1266
+ command,
1267
+ stdout=asyncio.subprocess.PIPE,
1268
+ stderr=asyncio.subprocess.STDOUT,
1269
+ cwd=cwd,
1270
+ )
1271
+ except Exception as exc:
1272
+ live.buffer = f"[shell error] {type(exc).__name__}: {exc}"
1273
+ self._busy = False
1274
+ try:
1275
+ self.query_one(ThinkingIndicator).busy = False
1276
+ except Exception:
1277
+ pass
1278
+ return
1279
+
1280
+ assert proc.stdout is not None
1281
+ buffer_lines: list[str] = []
1282
+ try:
1283
+ while True:
1284
+ raw = await proc.stdout.readline()
1285
+ if not raw:
1286
+ break
1287
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
1288
+ buffer_lines.append(line)
1289
+ # Cap displayed buffer so a runaway command doesn't grow
1290
+ # the widget until the chat pane stalls. Mirrors the
1291
+ # ``bash`` tool's 10K-char output truncation.
1292
+ joined = "\n".join(buffer_lines)
1293
+ if len(joined) > 10_000:
1294
+ head = joined[:10_000]
1295
+ live.buffer = head + "\n... (truncated, still running)"
1296
+ else:
1297
+ live.buffer = joined
1298
+ await proc.wait()
1299
+ except asyncio.CancelledError:
1300
+ try:
1301
+ proc.kill()
1302
+ except Exception:
1303
+ pass
1304
+ live.buffer = (live.buffer or "") + "\n[interrupted]"
1305
+ raise
1306
+ except Exception as exc:
1307
+ live.buffer = (live.buffer or "") + (
1308
+ f"\n[shell error] {type(exc).__name__}: {exc}"
1309
+ )
1310
+ finally:
1311
+ rc = proc.returncode if proc.returncode is not None else "?"
1312
+ tail = f"\n[exit {rc}]"
1313
+ current = live.buffer or ""
1314
+ if not current.endswith(tail):
1315
+ live.buffer = current + tail
1316
+ self._busy = False
1317
+ try:
1318
+ self.query_one(ThinkingIndicator).busy = False
1319
+ except Exception:
1320
+ pass
1321
+
1131
1322
  # Layer 14 — full set of DEC private modes that ``WindowsDriver
1132
1323
  # .start_application_mode`` enables at boot, minus alt-screen
1133
1324
  # (``?1049``, not idempotent — would save/restore the display
@@ -1914,9 +2105,24 @@ async def run_tui(
1914
2105
  ctx.tui_app = app
1915
2106
  ctx.ui = TuiUI(app)
1916
2107
 
2108
+ # Bridge logging → ChatPane so Agno's ERROR records (rate limits,
2109
+ # provider errors, etc.) are visible to the user instead of vanishing
2110
+ # into Textual's captured stderr. Detached in the finally block.
2111
+ log_bridge_handlers: list = []
2112
+ try:
2113
+ from aru.tui.log_bridge import install_chat_log_bridge
2114
+ log_bridge_handlers = install_chat_log_bridge(app)
2115
+ except Exception:
2116
+ pass
2117
+
1917
2118
  try:
1918
2119
  await app.run_async()
1919
2120
  finally:
2121
+ try:
2122
+ from aru.tui.log_bridge import uninstall_chat_log_bridge
2123
+ uninstall_chat_log_bridge(log_bridge_handlers)
2124
+ except Exception:
2125
+ pass
1920
2126
  ctx.tui_app = None
1921
2127
  ctx.ui = None
1922
2128
  try:
@@ -0,0 +1,128 @@
1
+ """Logging → ChatPane bridge for TUI mode.
2
+
3
+ Background
4
+ ----------
5
+ Agno (and other libraries) report API errors via Python's ``logging``
6
+ module — for example, an OpenRouter rate-limit looks like::
7
+
8
+ ERROR Rate limit error from OpenAI API: Error code: 429 ...
9
+ ERROR Error in Agent run: Provider returned error
10
+
11
+ In REPL mode these records reach the user because Python's default log
12
+ handler writes to ``sys.stderr`` and the terminal renders it directly.
13
+ In TUI mode Textual takes over the alternate screen and captures stdout
14
+ / stderr — those ERROR lines vanish, leaving the user staring at a
15
+ spinner that eventually stops without any message.
16
+
17
+ This module installs a ``logging.Handler`` that forwards qualifying
18
+ records into the running ``AruApp``'s ``ChatPane`` as system messages
19
+ via ``app.call_from_thread``. The handler is idempotent (a marker
20
+ attribute on each target logger prevents double-attachment when
21
+ ``run_tui`` is invoked twice in the same process, e.g. tests).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import Any
28
+
29
+ # Loggers we forward to chat. Keep this tight — capturing the root logger
30
+ # would also pick up debug noise from libraries that log at WARNING for
31
+ # routine state. We only want clearly-actionable messages.
32
+ _BRIDGE_LOGGERS: tuple[str, ...] = ("agno", "aru")
33
+
34
+ # Records below this level are dropped. ERROR is the right floor:
35
+ # WARNING from Agno is often non-actionable (e.g. "tool call schema
36
+ # coerced"). The user explicitly asked for transparency about *errors*.
37
+ _BRIDGE_LEVEL = logging.ERROR
38
+
39
+ # Sentinel attribute name set on a logger after we've attached our
40
+ # handler, so re-running the install is a no-op.
41
+ _INSTALLED_FLAG = "_aru_chat_bridge_installed"
42
+
43
+
44
+ class _ChatPaneLogHandler(logging.Handler):
45
+ """Forward logging records into the TUI ChatPane.
46
+
47
+ Holds a weak reference semantically (we let the App outlive the
48
+ handler — the handler is detached in ``uninstall_chat_log_bridge``).
49
+ Failures inside ``emit`` are swallowed: a logging handler must never
50
+ raise, and the user can still see the error via Textual's own dev
51
+ log if they really need it.
52
+ """
53
+
54
+ def __init__(self, app: Any) -> None:
55
+ super().__init__(level=_BRIDGE_LEVEL)
56
+ self._app = app
57
+ self.setFormatter(logging.Formatter("%(name)s: %(message)s"))
58
+
59
+ def emit(self, record: logging.LogRecord) -> None:
60
+ try:
61
+ text = self.format(record)
62
+ except Exception:
63
+ try:
64
+ text = record.getMessage()
65
+ except Exception:
66
+ return
67
+ try:
68
+ from aru.tui.widgets.chat import ChatPane
69
+ chat = self._app.query_one(ChatPane)
70
+ except Exception:
71
+ return
72
+ try:
73
+ self._app.call_from_thread(
74
+ chat.add_system_message, f"Error: {text}"
75
+ )
76
+ except Exception:
77
+ # Last-resort direct call — safe when already on the loop
78
+ # and the App is still running. If even this raises, drop
79
+ # the record silently rather than crashing the producer.
80
+ try:
81
+ chat.add_system_message(f"Error: {text}")
82
+ except Exception:
83
+ return
84
+
85
+
86
+ def install_chat_log_bridge(app: Any) -> list[logging.Handler]:
87
+ """Attach a ChatPane bridge to each target logger.
88
+
89
+ Returns the list of installed handlers so ``uninstall_chat_log_bridge``
90
+ can remove them on teardown. Idempotent per logger — if a previous
91
+ bridge from the same process is still attached, that logger is
92
+ skipped and only loggers without a bridge get a new handler.
93
+ """
94
+ installed: list[logging.Handler] = []
95
+ for name in _BRIDGE_LOGGERS:
96
+ logger = logging.getLogger(name)
97
+ if getattr(logger, _INSTALLED_FLAG, False):
98
+ continue
99
+ handler = _ChatPaneLogHandler(app)
100
+ logger.addHandler(handler)
101
+ # Make sure ERROR records actually fire — Agno ships at WARNING
102
+ # by default in cli.py, but a downstream user could lower it.
103
+ if logger.level == logging.NOTSET or logger.level > _BRIDGE_LEVEL:
104
+ logger.setLevel(_BRIDGE_LEVEL)
105
+ setattr(logger, _INSTALLED_FLAG, True)
106
+ installed.append(handler)
107
+ return installed
108
+
109
+
110
+ def uninstall_chat_log_bridge(handlers: list[logging.Handler]) -> None:
111
+ """Detach the bridge handlers and clear the per-logger marker."""
112
+ for name in _BRIDGE_LOGGERS:
113
+ logger = logging.getLogger(name)
114
+ for h in list(logger.handlers):
115
+ if isinstance(h, _ChatPaneLogHandler):
116
+ logger.removeHandler(h)
117
+ if getattr(logger, _INSTALLED_FLAG, False):
118
+ try:
119
+ delattr(logger, _INSTALLED_FLAG)
120
+ except AttributeError:
121
+ pass
122
+ # Best-effort close so file descriptors etc. don't leak (we don't
123
+ # use any, but a custom subclass might).
124
+ for h in handlers:
125
+ try:
126
+ h.close()
127
+ except Exception:
128
+ pass
@@ -130,6 +130,16 @@ class TextualBusSink:
130
130
  except Exception:
131
131
  pass
132
132
 
133
+ def on_error(self, message: str) -> None:
134
+ # Errors are routed to the ChatPane (persistent) instead of a
135
+ # Textual toast — under Textual, Agno's ERROR log lines never
136
+ # reach the terminal, so this is the user's only visible signal
137
+ # that something went wrong. Closing the active assistant bubble
138
+ # first prevents the streamed-but-empty assistant message from
139
+ # swallowing the error widget.
140
+ self._call(self.chat.finalize_assistant_message)
141
+ self._call(self.chat.add_system_message, f"Error: {message}")
142
+
133
143
  def on_stream_finished(self, *, final_content: str) -> None:
134
144
  self._call(self.chat.finalize_assistant_message, final_content or None)
135
145
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.46.0
3
+ Version: 0.48.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -69,6 +69,7 @@ aru/tools/web.py
69
69
  aru/tools/worktree.py
70
70
  aru/tui/__init__.py
71
71
  aru/tui/app.py
72
+ aru/tui/log_bridge.py
72
73
  aru/tui/sanitize.py
73
74
  aru/tui/sinks.py
74
75
  aru/tui/slash_bridge.py
@@ -149,6 +150,7 @@ tests/test_runner_interrupt.py
149
150
  tests/test_runner_recovery.py
150
151
  tests/test_runtime.py
151
152
  tests/test_select.py
153
+ tests/test_session_free_cost.py
152
154
  tests/test_skill_disallowed_tools.py
153
155
  tests/test_status_breakdown.py
154
156
  tests/test_status_cost.py
@@ -165,6 +167,7 @@ tests/test_tui_chat_adversarial.py
165
167
  tests/test_tui_completer.py
166
168
  tests/test_tui_completer_dynamic.py
167
169
  tests/test_tui_copy.py
170
+ tests/test_tui_error_display.py
168
171
  tests/test_tui_input_behaviour.py
169
172
  tests/test_tui_layer12_recovery.py
170
173
  tests/test_tui_layer13_recovery.py
@@ -174,8 +177,10 @@ tests/test_tui_mode_cycle.py
174
177
  tests/test_tui_native_selection.py
175
178
  tests/test_tui_permission_flow.py
176
179
  tests/test_tui_plan_task_render.py
180
+ tests/test_tui_shell_bang.py
177
181
  tests/test_tui_sidebar_toggle.py
178
182
  tests/test_tui_slash_bridge.py
183
+ tests/test_tui_slash_model.py
179
184
  tests/test_tui_snapshot_smoke.py
180
185
  tests/test_tui_thinking_and_boot.py
181
186
  tests/test_tui_widgets_visual.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.48.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,59 @@
1
+ """Free models must not accumulate dollar cost.
2
+
3
+ Regression: ``Session._get_pricing`` previously fell through to
4
+ ``MODEL_PRICING["default"]`` (Sonnet-4.5 pricing) for any model id not
5
+ present in the table. OpenRouter's `:free` variants like
6
+ ``openrouter/minimax/minimax-m2.5:free`` were therefore reported as
7
+ quite expensive in the status bar and `/cost` panel even though the
8
+ provider charges nothing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+
15
+ from aru.session import Session
16
+
17
+
18
+ def _consume(session: Session, *, inp: int, out: int) -> None:
19
+ session.total_input_tokens = inp
20
+ session.total_output_tokens = out
21
+
22
+
23
+ def test_openrouter_free_variant_costs_zero():
24
+ session = Session()
25
+ session.model_ref = "openrouter/minimax/minimax-m2.5:free"
26
+ _consume(session, inp=100_000, out=50_000)
27
+ assert session.estimated_cost == 0.0
28
+
29
+
30
+ def test_openrouter_free_nemotron_costs_zero():
31
+ session = Session()
32
+ session.model_ref = "openrouter/nvidia/nemotron-3-super-120b-a12b:free"
33
+ _consume(session, inp=200_000, out=10_000)
34
+ assert session.estimated_cost == 0.0
35
+
36
+
37
+ def test_paid_anthropic_still_costs_real_money():
38
+ session = Session()
39
+ session.model_ref = "anthropic/claude-sonnet-4-5"
40
+ _consume(session, inp=100_000, out=50_000)
41
+ assert session.estimated_cost > 0.0
42
+
43
+
44
+ def test_free_match_is_case_insensitive():
45
+ session = Session()
46
+ session.model_ref = "openrouter/minimax/minimax-m2.5:FREE"
47
+ _consume(session, inp=10_000, out=5_000)
48
+ assert session.estimated_cost == 0.0
49
+
50
+
51
+ def test_free_with_cache_tokens_still_zero():
52
+ """All four price components must be zeroed, not just input/output."""
53
+ session = Session()
54
+ session.model_ref = "openrouter/minimax/minimax-m2.5:free"
55
+ session.total_input_tokens = 100_000
56
+ session.total_output_tokens = 50_000
57
+ session.total_cache_read_tokens = 30_000
58
+ session.total_cache_write_tokens = 20_000
59
+ assert session.estimated_cost == 0.0
@@ -46,6 +46,9 @@ class RecordingSink:
46
46
  def notify(self, message: str, style: str = "") -> None:
47
47
  self.events.append(("notify", {"msg": message}))
48
48
 
49
+ def on_error(self, message: str) -> None:
50
+ self.events.append(("error", {"msg": message}))
51
+
49
52
  def on_stream_finished(self, *, final_content: str) -> None:
50
53
  self.events.append(("stream_finished", {"final": final_content}))
51
54