aru-code 0.47.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.47.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.47.0 → aru_code-0.48.0}/aru/runner.py +8 -2
  4. {aru_code-0.47.0 → aru_code-0.48.0}/aru/session.py +13 -1
  5. {aru_code-0.47.0 → aru_code-0.48.0}/aru/sinks.py +5 -0
  6. {aru_code-0.47.0 → aru_code-0.48.0}/aru/streaming.py +8 -0
  7. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/app.py +77 -8
  8. aru_code-0.48.0/aru/tui/log_bridge.py +128 -0
  9. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/sinks.py +10 -0
  10. {aru_code-0.47.0 → aru_code-0.48.0/aru_code.egg-info}/PKG-INFO +1 -1
  11. {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/SOURCES.txt +4 -0
  12. {aru_code-0.47.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.47.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_slash_model.py +141 -0
  17. aru_code-0.47.0/aru/__init__.py +0 -1
  18. {aru_code-0.47.0 → aru_code-0.48.0}/LICENSE +0 -0
  19. {aru_code-0.47.0 → aru_code-0.48.0}/README.md +0 -0
  20. {aru_code-0.47.0 → aru_code-0.48.0}/aru/agent_factory.py +0 -0
  21. {aru_code-0.47.0 → aru_code-0.48.0}/aru/agents/__init__.py +0 -0
  22. {aru_code-0.47.0 → aru_code-0.48.0}/aru/agents/base.py +0 -0
  23. {aru_code-0.47.0 → aru_code-0.48.0}/aru/agents/catalog.py +0 -0
  24. {aru_code-0.47.0 → aru_code-0.48.0}/aru/agents/planner.py +0 -0
  25. {aru_code-0.47.0 → aru_code-0.48.0}/aru/cache_patch.py +0 -0
  26. {aru_code-0.47.0 → aru_code-0.48.0}/aru/checkpoints.py +0 -0
  27. {aru_code-0.47.0 → aru_code-0.48.0}/aru/cli.py +0 -0
  28. {aru_code-0.47.0 → aru_code-0.48.0}/aru/commands.py +0 -0
  29. {aru_code-0.47.0 → aru_code-0.48.0}/aru/completers.py +0 -0
  30. {aru_code-0.47.0 → aru_code-0.48.0}/aru/config.py +0 -0
  31. {aru_code-0.47.0 → aru_code-0.48.0}/aru/context.py +0 -0
  32. {aru_code-0.47.0 → aru_code-0.48.0}/aru/display.py +0 -0
  33. {aru_code-0.47.0 → aru_code-0.48.0}/aru/events.py +0 -0
  34. {aru_code-0.47.0 → aru_code-0.48.0}/aru/format/__init__.py +0 -0
  35. {aru_code-0.47.0 → aru_code-0.48.0}/aru/format/manager.py +0 -0
  36. {aru_code-0.47.0 → aru_code-0.48.0}/aru/format/runner.py +0 -0
  37. {aru_code-0.47.0 → aru_code-0.48.0}/aru/history_blocks.py +0 -0
  38. {aru_code-0.47.0 → aru_code-0.48.0}/aru/lsp/__init__.py +0 -0
  39. {aru_code-0.47.0 → aru_code-0.48.0}/aru/lsp/client.py +0 -0
  40. {aru_code-0.47.0 → aru_code-0.48.0}/aru/lsp/manager.py +0 -0
  41. {aru_code-0.47.0 → aru_code-0.48.0}/aru/lsp/protocol.py +0 -0
  42. {aru_code-0.47.0 → aru_code-0.48.0}/aru/memory/__init__.py +0 -0
  43. {aru_code-0.47.0 → aru_code-0.48.0}/aru/memory/extractor.py +0 -0
  44. {aru_code-0.47.0 → aru_code-0.48.0}/aru/memory/loader.py +0 -0
  45. {aru_code-0.47.0 → aru_code-0.48.0}/aru/memory/store.py +0 -0
  46. {aru_code-0.47.0 → aru_code-0.48.0}/aru/permissions.py +0 -0
  47. {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugin_cache.py +0 -0
  48. {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/__init__.py +0 -0
  49. {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/custom_tools.py +0 -0
  50. {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/hooks.py +0 -0
  51. {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/manager.py +0 -0
  52. {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/tool_api.py +0 -0
  53. {aru_code-0.47.0 → aru_code-0.48.0}/aru/providers.py +0 -0
  54. {aru_code-0.47.0 → aru_code-0.48.0}/aru/runtime.py +0 -0
  55. {aru_code-0.47.0 → aru_code-0.48.0}/aru/select.py +0 -0
  56. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tool_policy.py +0 -0
  57. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/__init__.py +0 -0
  58. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/_diff.py +0 -0
  59. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/_shared.py +0 -0
  60. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/apply_patch.py +0 -0
  61. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/apply_patch_prompt.txt +0 -0
  62. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/ast_tools.py +0 -0
  63. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/codebase.py +0 -0
  64. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/delegate.py +0 -0
  65. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/delegate_prompt.txt +0 -0
  66. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/file_ops.py +0 -0
  67. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/gitignore.py +0 -0
  68. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/lsp.py +0 -0
  69. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/mcp_client.py +0 -0
  70. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/memory_tool.py +0 -0
  71. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/plan_mode.py +0 -0
  72. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/ranker.py +0 -0
  73. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/registry.py +0 -0
  74. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/search.py +0 -0
  75. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/shell.py +0 -0
  76. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/skill.py +0 -0
  77. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/tasklist.py +0 -0
  78. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/web.py +0 -0
  79. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/worktree.py +0 -0
  80. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/__init__.py +0 -0
  81. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/sanitize.py +0 -0
  82. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/__init__.py +0 -0
  83. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/choice.py +0 -0
  84. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/confirm.py +0 -0
  85. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/search.py +0 -0
  86. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/text_input.py +0 -0
  87. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/slash_bridge.py +0 -0
  88. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/ui.py +0 -0
  89. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/__init__.py +0 -0
  90. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/chat.py +0 -0
  91. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/completer.py +0 -0
  92. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/context_pane.py +0 -0
  93. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/header.py +0 -0
  94. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/inline_choice.py +0 -0
  95. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/loaded_pane.py +0 -0
  96. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/status.py +0 -0
  97. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/thinking.py +0 -0
  98. {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/tools.py +0 -0
  99. {aru_code-0.47.0 → aru_code-0.48.0}/aru/ui.py +0 -0
  100. {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/dependency_links.txt +0 -0
  101. {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/entry_points.txt +0 -0
  102. {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/requires.txt +0 -0
  103. {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/top_level.txt +0 -0
  104. {aru_code-0.47.0 → aru_code-0.48.0}/setup.cfg +0 -0
  105. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_agents_base.py +0 -0
  106. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_agents_md_coverage.py +0 -0
  107. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_apply_patch.py +0 -0
  108. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_async_tool_permission.py +0 -0
  109. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cache_patch_metrics.py +0 -0
  110. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cache_patch_stop_reason.py +0 -0
  111. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_catalog.py +0 -0
  112. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_chat_scrollable.py +0 -0
  113. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_checkpoints.py +0 -0
  114. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli.py +0 -0
  115. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_advanced.py +0 -0
  116. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_base.py +0 -0
  117. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_completers.py +0 -0
  118. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_new.py +0 -0
  119. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_run_cli.py +0 -0
  120. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_session.py +0 -0
  121. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_shell.py +0 -0
  122. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_codebase.py +0 -0
  123. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_confabulation_regression.py +0 -0
  124. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_config.py +0 -0
  125. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_context.py +0 -0
  126. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_context_pane.py +0 -0
  127. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cwd_awareness.py +0 -0
  128. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_delegate.py +0 -0
  129. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_events_backward_compat.py +0 -0
  130. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_events_schema.py +0 -0
  131. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_fork_ctx_concurrency.py +0 -0
  132. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_format.py +0 -0
  133. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_gitignore.py +0 -0
  134. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_guardrails_scenarios.py +0 -0
  135. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_invoke_skill.py +0 -0
  136. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_invoked_skills.py +0 -0
  137. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_loaded_pane_path.py +0 -0
  138. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_lsp.py +0 -0
  139. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_lsp_rename.py +0 -0
  140. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_main.py +0 -0
  141. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_markdown_to_text.py +0 -0
  142. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_mcp_client.py +0 -0
  143. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_mcp_health.py +0 -0
  144. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_memory.py +0 -0
  145. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_memory_tool.py +0 -0
  146. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_microcompact.py +0 -0
  147. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_permissions.py +0 -0
  148. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plan_mode_refactor.py +0 -0
  149. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plugin_cache.py +0 -0
  150. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plugin_errors.py +0 -0
  151. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plugin_hooks_v2.py +0 -0
  152. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plugins.py +0 -0
  153. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_providers.py +0 -0
  154. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_ranker.py +0 -0
  155. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_reasoning.py +0 -0
  156. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_runner_interrupt.py +0 -0
  157. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_runner_recovery.py +0 -0
  158. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_runtime.py +0 -0
  159. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_select.py +0 -0
  160. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_skill_disallowed_tools.py +0 -0
  161. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_status_breakdown.py +0 -0
  162. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_status_cost.py +0 -0
  163. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tasklist.py +0 -0
  164. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_thread_tool_timeout.py +0 -0
  165. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tool_policy.py +0 -0
  166. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_truncation_marker.py +0 -0
  167. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_app_boot.py +0 -0
  168. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_bindings.py +0 -0
  169. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_bus_flow.py +0 -0
  170. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_chat.py +0 -0
  171. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_chat_adversarial.py +0 -0
  172. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_completer.py +0 -0
  173. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_completer_dynamic.py +0 -0
  174. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_copy.py +0 -0
  175. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_input_behaviour.py +0 -0
  176. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_layer12_recovery.py +0 -0
  177. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_layer13_recovery.py +0 -0
  178. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_mention_expand.py +0 -0
  179. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_modals.py +0 -0
  180. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_mode_cycle.py +0 -0
  181. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_native_selection.py +0 -0
  182. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_permission_flow.py +0 -0
  183. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_plan_task_render.py +0 -0
  184. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_shell_bang.py +0 -0
  185. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_sidebar_toggle.py +0 -0
  186. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_slash_bridge.py +0 -0
  187. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_snapshot_smoke.py +0 -0
  188. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_thinking_and_boot.py +0 -0
  189. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_widgets_visual.py +0 -0
  190. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_ui_adapter.py +0 -0
  191. {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_worktree.py +0 -0
  192. {aru_code-0.47.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.47.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
  ...
@@ -877,22 +877,76 @@ class AruApp(App):
877
877
  if session is None:
878
878
  self._push_chat("No session.", "model")
879
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
+
880
890
  body = body.strip()
881
891
  if not body:
882
- current = getattr(session, "model_ref", "?")
883
- self._push_chat(
884
- f"Current model: {current}\n"
885
- f"Usage: /model <provider/name> (e.g. /model anthropic/claude-sonnet-4-5)",
886
- "model",
887
- )
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")
888
912
  return
913
+
889
914
  try:
890
- 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
+ )
891
930
  if self.ctx is not None:
892
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
893
944
  status = self.query_one(StatusPane)
894
945
  status._refresh_from_session()
895
- 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
+ )
896
950
  except Exception as exc:
897
951
  self._push_chat(f"model switch failed: {exc}", "model")
898
952
 
@@ -2051,9 +2105,24 @@ async def run_tui(
2051
2105
  ctx.tui_app = app
2052
2106
  ctx.ui = TuiUI(app)
2053
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
+
2054
2118
  try:
2055
2119
  await app.run_async()
2056
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
2057
2126
  ctx.tui_app = None
2058
2127
  ctx.ui = None
2059
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.47.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
@@ -177,6 +180,7 @@ tests/test_tui_plan_task_render.py
177
180
  tests/test_tui_shell_bang.py
178
181
  tests/test_tui_sidebar_toggle.py
179
182
  tests/test_tui_slash_bridge.py
183
+ tests/test_tui_slash_model.py
180
184
  tests/test_tui_snapshot_smoke.py
181
185
  tests/test_tui_thinking_and_boot.py
182
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.47.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
 
@@ -0,0 +1,205 @@
1
+ """TUI must surface LLM/runner errors instead of silencing them.
2
+
3
+ Two layers of coverage:
4
+
5
+ 1. **Sink-routed errors.** When the runner's catch-all (``runner.py:693``)
6
+ fires, it now calls ``sink.on_error(...)``. The TUI sink must mount
7
+ a system message in the ChatPane so the user sees "Error: ...".
8
+
9
+ 2. **Logging bridge.** Agno frequently catches API errors (e.g. OpenAI
10
+ 429), logs them at ERROR level, and continues without re-raising.
11
+ In REPL the records reach the terminal via stderr; in TUI Textual
12
+ captures stderr and the user sees nothing. The bridge installed by
13
+ ``install_chat_log_bridge`` must convert those records into ChatPane
14
+ system messages.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+
21
+ import pytest
22
+
23
+ pytest.importorskip("textual")
24
+
25
+
26
+ # ── Sink-routed errors ───────────────────────────────────────────────
27
+
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_textual_bus_sink_on_error_lands_in_chat_pane():
31
+ """``TextualBusSink.on_error`` must mount a system message."""
32
+ from aru.tui.app import AruApp
33
+ from aru.tui.sinks import TextualBusSink
34
+ from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
35
+
36
+ app = AruApp()
37
+ async with app.run_test() as pilot:
38
+ await pilot.pause()
39
+ chat = app.query_one(ChatPane)
40
+ sink = TextualBusSink(app=app, chat_pane=chat)
41
+ sink.on_error("Provider returned error")
42
+ await pilot.pause()
43
+ msgs = list(chat.query(ChatMessageWidget))
44
+ joined = " ".join(m.buffer for m in msgs)
45
+
46
+ assert "Provider returned error" in joined
47
+ assert "Error" in joined
48
+ # Must be a system-role message, not silently dropped.
49
+ assert any(m.role == "system" for m in msgs)
50
+
51
+
52
+ # ── Logging bridge ───────────────────────────────────────────────────
53
+
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_agno_error_log_reaches_chat_via_bridge():
57
+ """An Agno ``logger.error(...)`` call must appear in the chat."""
58
+ from aru.tui.app import AruApp
59
+ from aru.tui.log_bridge import (
60
+ install_chat_log_bridge,
61
+ uninstall_chat_log_bridge,
62
+ )
63
+ from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
64
+
65
+ app = AruApp()
66
+ handlers: list = []
67
+ async with app.run_test() as pilot:
68
+ await pilot.pause()
69
+ handlers = install_chat_log_bridge(app)
70
+ try:
71
+ logging.getLogger("agno").error(
72
+ "Rate limit error from OpenAI API: Error code: 429"
73
+ )
74
+ await pilot.pause()
75
+ chat = app.query_one(ChatPane)
76
+ msgs = list(chat.query(ChatMessageWidget))
77
+ joined = " ".join(m.buffer for m in msgs)
78
+ finally:
79
+ uninstall_chat_log_bridge(handlers)
80
+
81
+ assert "429" in joined
82
+ assert "Rate limit error" in joined
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_aru_logger_error_also_bridged():
87
+ """Aru's own loggers should also surface errors in the chat."""
88
+ from aru.tui.app import AruApp
89
+ from aru.tui.log_bridge import (
90
+ install_chat_log_bridge,
91
+ uninstall_chat_log_bridge,
92
+ )
93
+ from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
94
+
95
+ app = AruApp()
96
+ async with app.run_test() as pilot:
97
+ await pilot.pause()
98
+ handlers = install_chat_log_bridge(app)
99
+ try:
100
+ logging.getLogger("aru.runner").error("runner blew up: %s", "boom")
101
+ await pilot.pause()
102
+ chat = app.query_one(ChatPane)
103
+ joined = " ".join(
104
+ m.buffer for m in chat.query(ChatMessageWidget)
105
+ )
106
+ finally:
107
+ uninstall_chat_log_bridge(handlers)
108
+
109
+ assert "boom" in joined
110
+
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_warning_level_records_are_filtered_out():
114
+ """Bridge floor is ERROR; routine WARNINGs should not pollute chat."""
115
+ from aru.tui.app import AruApp
116
+ from aru.tui.log_bridge import (
117
+ install_chat_log_bridge,
118
+ uninstall_chat_log_bridge,
119
+ )
120
+ from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
121
+
122
+ app = AruApp()
123
+ async with app.run_test() as pilot:
124
+ await pilot.pause()
125
+ handlers = install_chat_log_bridge(app)
126
+ try:
127
+ logging.getLogger("agno").warning("schema coerced to dict")
128
+ await pilot.pause()
129
+ chat = app.query_one(ChatPane)
130
+ joined = " ".join(
131
+ m.buffer for m in chat.query(ChatMessageWidget)
132
+ )
133
+ finally:
134
+ uninstall_chat_log_bridge(handlers)
135
+
136
+ assert "schema coerced" not in joined
137
+
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_install_is_idempotent():
141
+ """Re-installing the bridge must not double-attach handlers."""
142
+ from aru.tui.app import AruApp
143
+ from aru.tui.log_bridge import (
144
+ _ChatPaneLogHandler,
145
+ install_chat_log_bridge,
146
+ uninstall_chat_log_bridge,
147
+ )
148
+
149
+ app = AruApp()
150
+ async with app.run_test() as pilot:
151
+ await pilot.pause()
152
+ first = install_chat_log_bridge(app)
153
+ second = install_chat_log_bridge(app)
154
+ try:
155
+ agno_handlers = [
156
+ h for h in logging.getLogger("agno").handlers
157
+ if isinstance(h, _ChatPaneLogHandler)
158
+ ]
159
+ # Exactly one bridge handler attached, regardless of install count.
160
+ assert len(agno_handlers) == 1
161
+ assert second == [] # second call returned no new handlers
162
+ finally:
163
+ uninstall_chat_log_bridge(first)
164
+ uninstall_chat_log_bridge(second)
165
+
166
+
167
+ @pytest.mark.asyncio
168
+ async def test_uninstall_removes_handlers():
169
+ """After uninstall, ERROR records must not leak into chat anymore."""
170
+ from aru.tui.app import AruApp
171
+ from aru.tui.log_bridge import (
172
+ _ChatPaneLogHandler,
173
+ install_chat_log_bridge,
174
+ uninstall_chat_log_bridge,
175
+ )
176
+
177
+ app = AruApp()
178
+ async with app.run_test() as pilot:
179
+ await pilot.pause()
180
+ handlers = install_chat_log_bridge(app)
181
+ uninstall_chat_log_bridge(handlers)
182
+ # No bridge handler should remain.
183
+ for name in ("agno", "aru"):
184
+ for h in logging.getLogger(name).handlers:
185
+ assert not isinstance(h, _ChatPaneLogHandler)
186
+
187
+
188
+ # ── Rich sink continues to work for REPL ─────────────────────────────
189
+
190
+
191
+ def test_rich_live_sink_on_error_prints_via_console():
192
+ """REPL path: the existing console.print behavior is preserved."""
193
+ from io import StringIO
194
+
195
+ from rich.console import Console
196
+
197
+ from aru.sinks import RichLiveSink
198
+
199
+ buf = StringIO()
200
+ console = Console(file=buf, force_terminal=False, color_system=None, width=120)
201
+ sink = RichLiveSink(console=console)
202
+ sink.on_error("Provider returned error")
203
+ out = buf.getvalue()
204
+ assert "Provider returned error" in out
205
+ assert "Error" in out