aru-code 0.56.0__tar.gz → 0.57.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aru_code-0.56.0/aru_code.egg-info → aru_code-0.57.0}/PKG-INFO +1 -1
- aru_code-0.57.0/aru/__init__.py +1 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/plan_mode.py +13 -1
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/app.py +35 -4
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/slash_bridge.py +11 -1
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/ui.py +51 -0
- {aru_code-0.56.0 → aru_code-0.57.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.56.0 → aru_code-0.57.0}/pyproject.toml +1 -1
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_plan_mode_refactor.py +54 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_permission_flow.py +111 -0
- aru_code-0.56.0/aru/__init__.py +0 -1
- {aru_code-0.56.0 → aru_code-0.57.0}/LICENSE +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/README.md +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/_debug/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/_debug/analyze_trace.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/_debug/loop_tracer.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/agent_factory.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/agents/base.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/agents/planner.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/cache_patch.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/checkpoints.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/cli.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/commands.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/completers.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/config.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/context.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/display.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/doom_loop.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/events.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/format/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/format/manager.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/format/runner.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/history_blocks.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/lsp/client.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/memory/loader.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/memory/store.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/permissions.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/providers.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/runner.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/runtime.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/select.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/session.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/sinks.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/streaming.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tool_policy.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/registry.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/search.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/shell.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/skill.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/web.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/log_bridge.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/notifications.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/screens/keymap.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/screens/session_picker.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/themes.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/chat.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/file_link.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/prompt_area.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/prompt_queue.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/subagent_panel.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/tasklist_panel.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru/ui.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru_code.egg-info/SOURCES.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/setup.cfg +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_catalog.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cli.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_codebase.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_config.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_context.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_delegate.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_doom_loop.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_format.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_lsp.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_main.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_memory.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_permission_timeout_suspension.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_permissions.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_plugins.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_providers.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_ranker.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_runtime.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_select.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_session_free_cost.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_subagent_tool_events.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_error_display.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_file_link.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_prompt_queue.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_shell_bang.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_slash_model.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_subagent_panel.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_theme.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_worktree.py +0 -0
- {aru_code-0.56.0 → aru_code-0.57.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.57.0"
|
|
@@ -24,6 +24,7 @@ specialized read-only tool set and instructions.
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import asyncio
|
|
27
28
|
import sys
|
|
28
29
|
|
|
29
30
|
from rich.panel import Panel
|
|
@@ -197,7 +198,18 @@ async def exit_plan_mode(plan: str) -> str:
|
|
|
197
198
|
session.set_plan(task=task_label, plan_content=plan_text)
|
|
198
199
|
n_steps = len(session.plan_steps)
|
|
199
200
|
|
|
200
|
-
|
|
201
|
+
# ``exit_plan_mode`` is an async tool, so ``agent_factory`` awaits it
|
|
202
|
+
# directly on the event-loop thread (no ``_thread_tool`` worker hop like
|
|
203
|
+
# sync tools get). ``_prompt_plan_approval`` is synchronous and in TUI
|
|
204
|
+
# mode invokes ``TuiUI.ask_choice`` → ``App.call_from_thread``, which
|
|
205
|
+
# raises "must run in a different thread from the app" when called from
|
|
206
|
+
# the loop thread it targets. Hop to a worker thread first so the modal
|
|
207
|
+
# dispatch path matches the sync-tool and runner-auto-exit paths
|
|
208
|
+
# (``runner.py`` does the same). ``asyncio.to_thread`` copies the
|
|
209
|
+
# contextvars snapshot, so ``get_ctx()`` inside still resolves.
|
|
210
|
+
approved, feedback = await asyncio.to_thread(
|
|
211
|
+
_prompt_plan_approval, session.plan_steps, n_steps
|
|
212
|
+
)
|
|
201
213
|
|
|
202
214
|
# The approval prompt already rendered the plan panel inline, so suppress
|
|
203
215
|
# the runner's coalesced end-of-batch render to avoid a duplicate.
|
|
@@ -873,14 +873,45 @@ class AruApp(App):
|
|
|
873
873
|
return False
|
|
874
874
|
|
|
875
875
|
def _run_bridged_slash(self, name: str, body: str) -> None:
|
|
876
|
-
"""Execute a bridged REPL handler and show its output in ChatPane.
|
|
876
|
+
"""Execute a bridged REPL handler and show its output in ChatPane.
|
|
877
|
+
|
|
878
|
+
Dispatched as a coroutine worker so the handler runs OFF the
|
|
879
|
+
event-loop thread. Some bridged handlers prompt — ``/memory clear``
|
|
880
|
+
calls ``ctx.ui.confirm``, which bridges to the App loop via
|
|
881
|
+
``App.call_from_thread`` and therefore *must* be invoked from a
|
|
882
|
+
thread other than the loop (otherwise it raises, or degrades to the
|
|
883
|
+
cancel default — see ``TuiUI._on_app_thread``). The worker hops the
|
|
884
|
+
handler to a thread via ``asyncio.to_thread`` so the loop stays free
|
|
885
|
+
to draw and service the modal, then marshals the captured output
|
|
886
|
+
back to the ChatPane.
|
|
887
|
+
"""
|
|
888
|
+
from aru.tui.slash_bridge import BRIDGED_COMMANDS
|
|
889
|
+
|
|
890
|
+
if name.lower() not in BRIDGED_COMMANDS:
|
|
891
|
+
return
|
|
892
|
+
self.run_worker(
|
|
893
|
+
self._run_bridged_slash_async(name, body),
|
|
894
|
+
name=f"slash-{name}",
|
|
895
|
+
group="slash-bridge",
|
|
896
|
+
exclusive=False,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
async def _run_bridged_slash_async(self, name: str, body: str) -> None:
|
|
900
|
+
"""Worker body for :meth:`_run_bridged_slash` — see its docstring."""
|
|
877
901
|
from aru.tui.slash_bridge import run_bridged
|
|
878
902
|
|
|
879
|
-
|
|
903
|
+
# Hop to a worker thread: the handler may block on a ``ctx.ui``
|
|
904
|
+
# prompt whose ModalScreen dispatch needs the loop to stay
|
|
905
|
+
# responsive. ``asyncio.to_thread`` copies the contextvars snapshot
|
|
906
|
+
# so the handler's ``get_ctx()`` still resolves.
|
|
907
|
+
handled, text = await asyncio.to_thread(run_bridged, name, body, self)
|
|
880
908
|
if not handled:
|
|
881
909
|
return
|
|
882
|
-
|
|
883
|
-
|
|
910
|
+
# Back on the loop after the await — safe to touch widgets.
|
|
911
|
+
try:
|
|
912
|
+
chat = self.query_one(ChatPane)
|
|
913
|
+
except Exception:
|
|
914
|
+
return
|
|
884
915
|
header = f"/{name}" + (f" {body}" if body else "")
|
|
885
916
|
chat.add_system_message(f"$ {header}\n{text}" if text else f"$ {header}")
|
|
886
917
|
|
|
@@ -119,7 +119,17 @@ def run_bridged(name: str, body: str, app: Any) -> tuple[bool, str]:
|
|
|
119
119
|
|
|
120
120
|
import aru.commands as cmds_module
|
|
121
121
|
original_console = cmds_module.console
|
|
122
|
-
|
|
122
|
+
# ``file=StringIO()`` keeps the live output off the raw terminal — we
|
|
123
|
+
# only want the recorded copy (``export_text`` below), which we mirror
|
|
124
|
+
# into the ChatPane. Writing to stdout here is pointless under Textual
|
|
125
|
+
# (it owns the screen) and outright unsafe now that the handler runs on
|
|
126
|
+
# a worker thread; ``record=True`` still captures everything regardless
|
|
127
|
+
# of the file target.
|
|
128
|
+
import io
|
|
129
|
+
temp = Console(
|
|
130
|
+
record=True, width=100, force_terminal=True, color_system=None,
|
|
131
|
+
file=io.StringIO(),
|
|
132
|
+
)
|
|
123
133
|
cmds_module.console = temp
|
|
124
134
|
try:
|
|
125
135
|
handler(*args, **kwargs)
|
|
@@ -23,10 +23,14 @@ Contract notes:
|
|
|
23
23
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
26
28
|
from typing import Any, Sequence
|
|
27
29
|
|
|
28
30
|
from aru.tui.screens import ChoiceModal, ConfirmModal, TextInputModal
|
|
29
31
|
|
|
32
|
+
_log = logging.getLogger("aru.tui.ui")
|
|
33
|
+
|
|
30
34
|
|
|
31
35
|
class TuiUI:
|
|
32
36
|
"""UIAdapter backed by Textual ModalScreens."""
|
|
@@ -34,6 +38,28 @@ class TuiUI:
|
|
|
34
38
|
def __init__(self, app: Any) -> None:
|
|
35
39
|
self.app = app
|
|
36
40
|
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _on_app_thread() -> bool:
|
|
43
|
+
"""True when the caller is running on an asyncio event-loop thread.
|
|
44
|
+
|
|
45
|
+
``TuiUI``'s blocking prompts bridge to the App loop via
|
|
46
|
+
``App.call_from_thread``, which *requires* the caller to be on a
|
|
47
|
+
DIFFERENT thread than the loop (otherwise it raises rather than
|
|
48
|
+
deadlock). The correct callers reach us from ``asyncio.to_thread`` /
|
|
49
|
+
``_thread_tool`` workers — plain threads with no running loop. If a
|
|
50
|
+
loop IS running in this thread, we are on the App's own event-loop
|
|
51
|
+
thread and must not block: that would freeze the loop so the prompt
|
|
52
|
+
could never be drawn or answered. The guards below degrade safely in
|
|
53
|
+
that case instead of surfacing the cryptic ``call_from_thread``
|
|
54
|
+
``RuntimeError``. The real fix for any such caller is to wrap the
|
|
55
|
+
call in ``await asyncio.to_thread(ctx.ui.<method>, ...)``.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
asyncio.get_running_loop()
|
|
59
|
+
return True
|
|
60
|
+
except RuntimeError:
|
|
61
|
+
return False
|
|
62
|
+
|
|
37
63
|
# ── choice ────────────────────────────────────────────────────────
|
|
38
64
|
|
|
39
65
|
def ask_choice(
|
|
@@ -88,6 +114,19 @@ class TuiUI:
|
|
|
88
114
|
user answers, mirroring ``_run_modal`` so the sync call sites
|
|
89
115
|
(``check_permission``, plan approval) keep their contract.
|
|
90
116
|
"""
|
|
117
|
+
if self._on_app_thread():
|
|
118
|
+
# Contract violation (see ``_on_app_thread``): a caller dispatched
|
|
119
|
+
# this blocking prompt from the App's own event-loop thread. We
|
|
120
|
+
# cannot show it here, so degrade to ``cancel_value`` rather than
|
|
121
|
+
# crash the turn with ``call_from_thread``'s RuntimeError.
|
|
122
|
+
_log.warning(
|
|
123
|
+
"TuiUI.ask_choice invoked on the event-loop thread; returning "
|
|
124
|
+
"cancel_value=%r without prompting. Wrap the call site in "
|
|
125
|
+
"asyncio.to_thread.",
|
|
126
|
+
cancel_value,
|
|
127
|
+
)
|
|
128
|
+
return cancel_value
|
|
129
|
+
|
|
91
130
|
import threading
|
|
92
131
|
|
|
93
132
|
from aru.tui.widgets.chat import ChatPane
|
|
@@ -252,6 +291,18 @@ class TuiUI:
|
|
|
252
291
|
we work without an active Textual worker context. Designed to be
|
|
253
292
|
called from ``asyncio.to_thread`` tool threads.
|
|
254
293
|
"""
|
|
294
|
+
if self._on_app_thread():
|
|
295
|
+
# See ``_on_app_thread``: blocking on the loop thread would
|
|
296
|
+
# freeze the App. Degrade to ``None`` (callers map this to their
|
|
297
|
+
# default / cancel outcome) instead of raising.
|
|
298
|
+
_log.warning(
|
|
299
|
+
"TuiUI modal (%s) invoked on the event-loop thread; "
|
|
300
|
+
"dismissing without prompting. Wrap the call site in "
|
|
301
|
+
"asyncio.to_thread.",
|
|
302
|
+
type(modal).__name__,
|
|
303
|
+
)
|
|
304
|
+
return None
|
|
305
|
+
|
|
255
306
|
import threading
|
|
256
307
|
|
|
257
308
|
done = threading.Event()
|
|
@@ -245,6 +245,60 @@ class TestExitPlanMode:
|
|
|
245
245
|
assert "needs more detail" in result
|
|
246
246
|
assert "STILL in plan mode" in result
|
|
247
247
|
|
|
248
|
+
@pytest.mark.asyncio
|
|
249
|
+
async def test_approval_prompt_runs_off_event_loop_thread(self):
|
|
250
|
+
"""Regression: the approval prompt must run on a worker thread, never
|
|
251
|
+
the event-loop thread.
|
|
252
|
+
|
|
253
|
+
``exit_plan_mode`` is an async tool awaited directly on the loop. In
|
|
254
|
+
TUI mode ``_prompt_plan_approval`` reaches ``TuiUI.ask_choice`` →
|
|
255
|
+
``App.call_from_thread``, which raises "must run in a different
|
|
256
|
+
thread from the app" when called on the loop thread. The fix hops to
|
|
257
|
+
a worker via ``asyncio.to_thread``; this test asserts the prompt
|
|
258
|
+
observes a thread id different from the loop's. Before the fix the
|
|
259
|
+
ids matched and TUI users hit the RuntimeError.
|
|
260
|
+
"""
|
|
261
|
+
import threading
|
|
262
|
+
|
|
263
|
+
from aru.runtime import RuntimeContext, _runtime_ctx as _ctx
|
|
264
|
+
from aru.tools import plan_mode as plan_mode_module
|
|
265
|
+
|
|
266
|
+
loop_thread_id = threading.get_ident()
|
|
267
|
+
captured: dict = {}
|
|
268
|
+
|
|
269
|
+
def _fake_prompt(plan_steps, n_steps):
|
|
270
|
+
# Runs inside exit_plan_mode's approval call. Record the thread.
|
|
271
|
+
captured["thread_id"] = threading.get_ident()
|
|
272
|
+
# Also prove get_ctx() still resolves across the to_thread hop.
|
|
273
|
+
from aru.runtime import get_ctx
|
|
274
|
+
captured["has_ctx_session"] = get_ctx().session is not None
|
|
275
|
+
return (True, "")
|
|
276
|
+
|
|
277
|
+
session = Session()
|
|
278
|
+
session.plan_mode = True
|
|
279
|
+
ctx = RuntimeContext()
|
|
280
|
+
ctx.session = session
|
|
281
|
+
token = _ctx.set(ctx)
|
|
282
|
+
with patch.object(plan_mode_module, "_prompt_plan_approval", _fake_prompt):
|
|
283
|
+
try:
|
|
284
|
+
result = await plan_mode_module.exit_plan_mode(
|
|
285
|
+
plan="## Steps\n1. do thing\n2. other thing"
|
|
286
|
+
)
|
|
287
|
+
finally:
|
|
288
|
+
_ctx.reset(token)
|
|
289
|
+
|
|
290
|
+
assert "thread_id" in captured, "approval prompt was never invoked"
|
|
291
|
+
assert captured["thread_id"] != loop_thread_id, (
|
|
292
|
+
"approval prompt ran on the event-loop thread — "
|
|
293
|
+
"TuiUI.ask_choice's call_from_thread would raise in TUI mode"
|
|
294
|
+
)
|
|
295
|
+
assert captured["has_ctx_session"], (
|
|
296
|
+
"contextvars must propagate across asyncio.to_thread so the "
|
|
297
|
+
"prompt can still read ctx.session"
|
|
298
|
+
)
|
|
299
|
+
assert session.plan_mode is False
|
|
300
|
+
assert "approved" in result.lower()
|
|
301
|
+
|
|
248
302
|
|
|
249
303
|
# ── Tool-wrapper plan-mode gate ───────────────────────────────────────
|
|
250
304
|
|
|
@@ -366,6 +366,53 @@ async def test_thinking_spinner_hidden_while_prompt_open():
|
|
|
366
366
|
assert holder["choice"] == 0
|
|
367
367
|
|
|
368
368
|
|
|
369
|
+
@pytest.mark.asyncio
|
|
370
|
+
async def test_blocking_prompt_on_event_loop_thread_degrades_not_raises():
|
|
371
|
+
"""Defense-in-depth (bug class): a blocking TuiUI prompt invoked directly
|
|
372
|
+
on the App's event-loop thread must NOT raise the cryptic
|
|
373
|
+
'call_from_thread must run in a different thread' RuntimeError.
|
|
374
|
+
|
|
375
|
+
The correct fix for any *known* caller is to hop to a worker thread
|
|
376
|
+
(asyncio.to_thread) — this guard is the safety net that keeps a stray
|
|
377
|
+
on-loop call (e.g. the /memory clear slash bridge) from crashing a turn.
|
|
378
|
+
It degrades to cancel_value / the caller default instead.
|
|
379
|
+
"""
|
|
380
|
+
from rich.panel import Panel
|
|
381
|
+
|
|
382
|
+
from aru.tui.ui import TuiUI
|
|
383
|
+
|
|
384
|
+
class _DummyApp:
|
|
385
|
+
# If the guard fails, _run_inline_choice/_run_modal would reach this
|
|
386
|
+
# and we'd see the assertion instead of a clean degrade.
|
|
387
|
+
def call_from_thread(self, *args, **kwargs):
|
|
388
|
+
raise AssertionError(
|
|
389
|
+
"call_from_thread must not be reached on the loop thread"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
ui = TuiUI(_DummyApp())
|
|
393
|
+
# This coroutine runs on the event loop, so we ARE on the loop thread.
|
|
394
|
+
assert TuiUI._on_app_thread() is True
|
|
395
|
+
|
|
396
|
+
# Inline path (details present) — the exact shape of the plan-approval /
|
|
397
|
+
# edit-permission prompt. Degrades to cancel_value.
|
|
398
|
+
inline = ui.ask_choice(
|
|
399
|
+
["Yes", "No"],
|
|
400
|
+
title="Approve?",
|
|
401
|
+
default=0,
|
|
402
|
+
cancel_value=99,
|
|
403
|
+
details=Panel("preview"),
|
|
404
|
+
)
|
|
405
|
+
assert inline == 99
|
|
406
|
+
|
|
407
|
+
# Modal path (no details) — degrades to None.
|
|
408
|
+
modal = ui.ask_choice(["Yes", "No"], title="Pick", default=0, cancel_value=None)
|
|
409
|
+
assert modal is None
|
|
410
|
+
|
|
411
|
+
# confirm() maps the degraded None back to the caller's default.
|
|
412
|
+
assert ui.confirm("Proceed?", default=True) is True
|
|
413
|
+
assert ui.confirm("Proceed?", default=False) is False
|
|
414
|
+
|
|
415
|
+
|
|
369
416
|
@pytest.mark.asyncio
|
|
370
417
|
async def test_tui_confirm_from_worker_returns_bool():
|
|
371
418
|
from aru.tui.app import AruApp
|
|
@@ -390,3 +437,67 @@ async def test_tui_confirm_from_worker_returns_bool():
|
|
|
390
437
|
await pilot.press("y")
|
|
391
438
|
await asyncio.wait_for(worker_task, timeout=5.0)
|
|
392
439
|
assert result_holder["answer"] is True
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@pytest.mark.asyncio
|
|
443
|
+
async def test_memory_clear_slash_shows_confirm_modal_and_clears():
|
|
444
|
+
"""Regression: ``/memory clear`` in the TUI must actually show the
|
|
445
|
+
ConfirmModal (and clear on "yes"), not silently degrade.
|
|
446
|
+
|
|
447
|
+
The slash bridge used to run handlers inline on the event-loop thread,
|
|
448
|
+
so ``handle_memory_command`` → ``ui.confirm`` → ``call_from_thread``
|
|
449
|
+
raised / degraded to the cancel default and the modal never appeared.
|
|
450
|
+
The bridge now hops the handler to a worker thread (``_run_bridged_slash``
|
|
451
|
+
→ ``asyncio.to_thread``), so the modal is dispatched correctly and the
|
|
452
|
+
user's answer is honoured.
|
|
453
|
+
"""
|
|
454
|
+
from unittest.mock import patch
|
|
455
|
+
|
|
456
|
+
from aru.runtime import init_ctx, set_ctx
|
|
457
|
+
from aru.session import Session
|
|
458
|
+
from aru.tui.app import AruApp
|
|
459
|
+
from aru.tui.screens import ConfirmModal
|
|
460
|
+
from aru.tui.ui import TuiUI
|
|
461
|
+
|
|
462
|
+
ctx = init_ctx()
|
|
463
|
+
session = Session()
|
|
464
|
+
session.project_root = "/tmp/aru-memory-clear-test"
|
|
465
|
+
app = AruApp(ctx=ctx, session=session)
|
|
466
|
+
ctx.tui_app = app
|
|
467
|
+
|
|
468
|
+
cleared: dict = {}
|
|
469
|
+
|
|
470
|
+
def _fake_clear(project_root, *args, **kwargs):
|
|
471
|
+
cleared["root"] = project_root
|
|
472
|
+
return 3
|
|
473
|
+
|
|
474
|
+
async with app.run_test() as pilot:
|
|
475
|
+
await pilot.pause()
|
|
476
|
+
ctx.ui = TuiUI(app)
|
|
477
|
+
set_ctx(ctx) # ensure the worker task + its to_thread child inherit ctx
|
|
478
|
+
# Patch the destructive clear so the test never touches real memory;
|
|
479
|
+
# the patch stays active across the awaits below, covering the
|
|
480
|
+
# handler's execution on the worker thread.
|
|
481
|
+
with patch("aru.memory.store.clear_memory", _fake_clear):
|
|
482
|
+
app._run_bridged_slash("memory", "clear")
|
|
483
|
+
# The crux of the fix: the modal actually appears. Before the
|
|
484
|
+
# fix the handler ran on the loop thread and degraded silently.
|
|
485
|
+
for _ in range(60):
|
|
486
|
+
await pilot.pause(0.05)
|
|
487
|
+
if app.screen_stack and isinstance(app.screen, ConfirmModal):
|
|
488
|
+
break
|
|
489
|
+
assert isinstance(app.screen, ConfirmModal), (
|
|
490
|
+
"ConfirmModal never appeared — /memory clear degraded instead "
|
|
491
|
+
"of prompting (handler ran on the event-loop thread)"
|
|
492
|
+
)
|
|
493
|
+
# Answer "yes" and let the worker finish + run the (patched) clear.
|
|
494
|
+
await pilot.press("y")
|
|
495
|
+
for _ in range(60):
|
|
496
|
+
await pilot.pause(0.05)
|
|
497
|
+
if "root" in cleared:
|
|
498
|
+
break
|
|
499
|
+
|
|
500
|
+
assert "root" in cleared, (
|
|
501
|
+
"clear_memory was never called — the 'yes' answer did not propagate "
|
|
502
|
+
"back through the worker to the handler"
|
|
503
|
+
)
|
aru_code-0.56.0/aru/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.56.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|