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.
- {aru_code-0.46.0/aru_code.egg-info → aru_code-0.48.0}/PKG-INFO +1 -1
- aru_code-0.48.0/aru/__init__.py +1 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/runner.py +8 -2
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/session.py +13 -1
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/sinks.py +5 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/streaming.py +8 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/app.py +214 -8
- aru_code-0.48.0/aru/tui/log_bridge.py +128 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/sinks.py +10 -0
- {aru_code-0.46.0 → aru_code-0.48.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/SOURCES.txt +5 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/pyproject.toml +1 -1
- aru_code-0.48.0/tests/test_session_free_cost.py +59 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_streaming_sink.py +3 -0
- aru_code-0.48.0/tests/test_tui_error_display.py +205 -0
- aru_code-0.48.0/tests/test_tui_shell_bang.py +183 -0
- aru_code-0.48.0/tests/test_tui_slash_model.py +141 -0
- aru_code-0.46.0/aru/__init__.py +0 -1
- {aru_code-0.46.0 → aru_code-0.48.0}/LICENSE +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/README.md +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/agent_factory.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/agents/base.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/agents/planner.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/cache_patch.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/checkpoints.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/cli.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/commands.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/completers.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/config.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/context.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/display.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/events.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/format/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/format/manager.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/format/runner.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/history_blocks.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/lsp/client.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/memory/loader.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/memory/store.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/permissions.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/providers.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/runtime.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/select.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tool_policy.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/registry.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/search.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/shell.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/skill.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/web.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/ui.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/chat.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru/ui.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/setup.cfg +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_catalog.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_codebase.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_config.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_context.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_delegate.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_format.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_lsp.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_main.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_memory.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_permissions.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_plugins.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_providers.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_ranker.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_runtime.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_select.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_permission_flow.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_worktree.py +0 -0
- {aru_code-0.46.0 → aru_code-0.48.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -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
|
-
|
|
699
|
-
console
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
|
@@ -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
|
|
@@ -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
|
|