aru-code 0.37.0__tar.gz → 0.38.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.37.0/aru_code.egg-info → aru_code-0.38.0}/PKG-INFO +1 -1
- aru_code-0.38.0/aru/__init__.py +1 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/chat.py +217 -19
- {aru_code-0.37.0 → aru_code-0.38.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.37.0 → aru_code-0.38.0}/pyproject.toml +1 -1
- aru_code-0.37.0/aru/__init__.py +0 -1
- {aru_code-0.37.0 → aru_code-0.38.0}/LICENSE +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/README.md +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/agent_factory.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/agents/base.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/agents/planner.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/cache_patch.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/checkpoints.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/cli.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/commands.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/completers.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/config.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/context.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/display.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/events.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/format/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/format/manager.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/format/runner.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/history_blocks.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/lsp/client.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/memory/loader.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/memory/store.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/permissions.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/providers.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/runner.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/runtime.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/select.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/session.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/sinks.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/streaming.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tool_policy.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/registry.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/search.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/shell.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/skill.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/web.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/app.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/ui.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru/ui.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/SOURCES.txt +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/setup.cfg +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_catalog.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_codebase.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_config.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_context.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_delegate.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_format.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_lsp.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_main.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_memory.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_permissions.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plugins.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_providers.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_ranker.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_runtime.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_select.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_permission_flow.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_worktree.py +0 -0
- {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.38.0"
|
|
@@ -10,14 +10,53 @@ Design (per plan-reviewer):
|
|
|
10
10
|
* NO mutation of ``RichLog.lines[-1]`` — that is Textual internal state.
|
|
11
11
|
* Each assistant message is its own widget with a reactive ``buffer``;
|
|
12
12
|
Textual's reactive system re-renders when it changes.
|
|
13
|
-
* ``set_interval(0.
|
|
13
|
+
* ``set_interval(0.1, _flush)`` debounces rapid content deltas so we
|
|
14
14
|
don't re-render on every single token.
|
|
15
15
|
* Tool calls show inline with a cycling indicator that flips to a check
|
|
16
16
|
when the tool completes.
|
|
17
|
+
|
|
18
|
+
----
|
|
19
|
+
|
|
20
|
+
Post-mortem — scroll freeze (2026-04-22, ``fix/tui-freezing``)
|
|
21
|
+
--------------------------------------------------------------
|
|
22
|
+
**Symptom:** vertical scroll froze for seconds mid-stream while the
|
|
23
|
+
agent kept producing tokens.
|
|
24
|
+
|
|
25
|
+
**Cause:** ``watch_buffer`` re-parsed the whole (growing) buffer
|
|
26
|
+
through Rich.Markdown on the UI thread every flush — O(N²) over the
|
|
27
|
+
turn. Past ~5 KB (52 ms/parse at 20 Hz) the loop had no budget left
|
|
28
|
+
for scroll / input / paint. ``scroll_end(animate=False)`` compounded
|
|
29
|
+
it by queuing behind ``call_after_refresh``.
|
|
30
|
+
|
|
31
|
+
**Fix, four layers:**
|
|
32
|
+
|
|
33
|
+
1. ``asyncio.to_thread`` in ``_schedule_markdown_render`` moves the
|
|
34
|
+
parse off the UI thread. ``_markdown_to_text`` still flattens to
|
|
35
|
+
one ``Text`` so mouse selection + Ctrl+C keep working (Textual's
|
|
36
|
+
native ``Markdown`` widget was rejected: its composite blocks
|
|
37
|
+
break selection).
|
|
38
|
+
2. Coalescing: one render task per widget; newer deltas cause the
|
|
39
|
+
in-flight result to be discarded and re-rendered on the freshest
|
|
40
|
+
snapshot — avoids N intermediate ``update`` + layout passes.
|
|
41
|
+
3. 250 ms cooldown between renders: pure-Python parse still contends
|
|
42
|
+
with the UI thread for the GIL; cooldown cut "notable freeze"
|
|
43
|
+
iterations from 55 % to 15 % in the adversarial bench.
|
|
44
|
+
4. ``self.anchor()`` replaces manual ``scroll_end`` on every event —
|
|
45
|
+
compositor auto-follows when anchored, respects manual scroll-up,
|
|
46
|
+
re-engages on scroll-to-bottom. ``add_user_message`` keeps an
|
|
47
|
+
explicit ``scroll_end(immediate=True)`` so submitted messages are
|
|
48
|
+
always visible.
|
|
49
|
+
|
|
50
|
+
**If the freeze comes back** (very large + code-block-dense replies
|
|
51
|
+
>30 KB), next lever is incremental rendering: split at the last
|
|
52
|
+
stable markdown boundary (blank line outside a fence), cache the
|
|
53
|
+
prefix ``Text``, re-render only the tail. Rich has no incremental
|
|
54
|
+
API but the split is tractable.
|
|
17
55
|
"""
|
|
18
56
|
|
|
19
57
|
from __future__ import annotations
|
|
20
58
|
|
|
59
|
+
import asyncio
|
|
21
60
|
import io
|
|
22
61
|
from typing import Any
|
|
23
62
|
|
|
@@ -98,6 +137,12 @@ class ChatMessageWidget(Static):
|
|
|
98
137
|
super().__init__("")
|
|
99
138
|
self.role = role
|
|
100
139
|
self.tool_state = tool_state
|
|
140
|
+
# Async markdown render state — see _schedule_markdown_render. Keeps
|
|
141
|
+
# the Rich.Markdown parse off the UI thread so the event loop can
|
|
142
|
+
# process scroll / keyboard / paint events even while a 20KB+
|
|
143
|
+
# assistant reply is streaming in.
|
|
144
|
+
self._md_render_task: asyncio.Task | None = None
|
|
145
|
+
self._pending_md_render: bool = False
|
|
101
146
|
self.set_reactive(ChatMessageWidget.buffer, initial)
|
|
102
147
|
if role in ("user", "assistant", "system", "tool"):
|
|
103
148
|
self.add_class(role)
|
|
@@ -108,7 +153,16 @@ class ChatMessageWidget(Static):
|
|
|
108
153
|
self.update(self._compose_renderable())
|
|
109
154
|
|
|
110
155
|
def watch_buffer(self, _old: str, _new: str) -> None:
|
|
111
|
-
# Reactive watcher — repaint
|
|
156
|
+
# Reactive watcher — repaint when the buffer changes.
|
|
157
|
+
# Assistant bubbles get their markdown rendered off-thread so the
|
|
158
|
+
# UI stays responsive; everything else renders synchronously
|
|
159
|
+
# because the cost is trivial (plain Text, ~O(len)).
|
|
160
|
+
if self.role == "assistant":
|
|
161
|
+
if _new:
|
|
162
|
+
self._schedule_markdown_render()
|
|
163
|
+
else:
|
|
164
|
+
self.update(Text(""))
|
|
165
|
+
return
|
|
112
166
|
self.update(self._compose_renderable())
|
|
113
167
|
|
|
114
168
|
def _compose_renderable(self) -> Any:
|
|
@@ -133,6 +187,119 @@ class ChatMessageWidget(Static):
|
|
|
133
187
|
# system
|
|
134
188
|
return Text(text)
|
|
135
189
|
|
|
190
|
+
def _schedule_markdown_render(self) -> None:
|
|
191
|
+
"""Queue an off-thread markdown re-render of the assistant buffer.
|
|
192
|
+
|
|
193
|
+
Why: Rich's ``Markdown`` engine parses the whole document on every
|
|
194
|
+
call and — with code blocks / Pygments highlighting — costs
|
|
195
|
+
~100 ms for a 10 KB buffer and ~400 ms for a 40 KB buffer.
|
|
196
|
+
Running that on the UI thread at 20 Hz (the debounce rate) used to
|
|
197
|
+
starve scroll / mouse / keyboard events once a reply grew past a
|
|
198
|
+
few KB, making the chat pane feel frozen even though the agent
|
|
199
|
+
loop was still progressing.
|
|
200
|
+
|
|
201
|
+
We hand the parse off to ``asyncio.to_thread``. The render output
|
|
202
|
+
is byte-identical (``_markdown_to_text`` flattens to a single
|
|
203
|
+
``Text`` so click-and-drag text selection still walks characters,
|
|
204
|
+
preserving Ctrl+C copy UX).
|
|
205
|
+
|
|
206
|
+
Coalescing rule: if a render is still in flight, we flip
|
|
207
|
+
``_pending_md_render`` and let the running task loop back with
|
|
208
|
+
the freshest buffer when it finishes — so the executor never sees
|
|
209
|
+
more than one render in flight per widget even under a burst of
|
|
210
|
+
deltas, and the most recent buffer always wins.
|
|
211
|
+
"""
|
|
212
|
+
if self._md_render_task is not None and not self._md_render_task.done():
|
|
213
|
+
self._pending_md_render = True
|
|
214
|
+
return
|
|
215
|
+
try:
|
|
216
|
+
self._md_render_task = asyncio.create_task(self._do_markdown_render())
|
|
217
|
+
except RuntimeError:
|
|
218
|
+
# No running loop (shouldn't happen inside a Textual app, but
|
|
219
|
+
# defensive) — fall back to a synchronous render so the widget
|
|
220
|
+
# still paints *something*. The event loop starvation issue
|
|
221
|
+
# this method exists to solve is moot without a loop anyway.
|
|
222
|
+
self.update(self._compose_renderable())
|
|
223
|
+
|
|
224
|
+
# Minimum interval between successive markdown renders of the same
|
|
225
|
+
# bubble. Even off-thread the Rich.Markdown parse competes with the
|
|
226
|
+
# UI thread for the GIL (pure-Python work), so two renders running
|
|
227
|
+
# back-to-back while deltas stream in can still starve the loop.
|
|
228
|
+
# 250 ms is below the human streaming-text reading rate (text flows
|
|
229
|
+
# faster than the eye tracks individual updates past ~3 Hz anyway)
|
|
230
|
+
# and cuts per-widget render CPU by 3–4× compared to running
|
|
231
|
+
# flat-out on every flush.
|
|
232
|
+
_MD_RENDER_COOLDOWN_SEC: float = 0.25
|
|
233
|
+
|
|
234
|
+
async def _do_markdown_render(self) -> None:
|
|
235
|
+
"""Background loop: render the current buffer off-thread, apply on return.
|
|
236
|
+
|
|
237
|
+
Loops while ``_pending_md_render`` keeps flipping on — this handles
|
|
238
|
+
the bursty streaming case where new deltas land while we're mid-
|
|
239
|
+
render. We always apply the freshest snapshot we actually rendered,
|
|
240
|
+
and insert a cooldown before re-rendering so a fast stream doesn't
|
|
241
|
+
pin a worker at 100 % and starve the event loop via GIL pressure.
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
while True:
|
|
245
|
+
snapshot = self.buffer
|
|
246
|
+
if not snapshot:
|
|
247
|
+
return
|
|
248
|
+
width = max(self.size.width or 100, 20)
|
|
249
|
+
try:
|
|
250
|
+
result = await asyncio.to_thread(
|
|
251
|
+
_markdown_to_text, snapshot, width
|
|
252
|
+
)
|
|
253
|
+
except asyncio.CancelledError:
|
|
254
|
+
raise
|
|
255
|
+
except Exception:
|
|
256
|
+
return
|
|
257
|
+
# We may have been superseded (e.g. finalize_render cancelled
|
|
258
|
+
# us and painted synchronously); drop the stale result.
|
|
259
|
+
if self._md_render_task is not asyncio.current_task():
|
|
260
|
+
return
|
|
261
|
+
# If more deltas landed while we were rendering, skip
|
|
262
|
+
# applying this already-stale snapshot and loop back to
|
|
263
|
+
# render the newer one — each ``self.update`` triggers a
|
|
264
|
+
# layout pass in the compositor, and painting an
|
|
265
|
+
# intermediate frame that will be overwritten in ~30 ms
|
|
266
|
+
# is pure waste at the heart of the UI thread. This is
|
|
267
|
+
# the coalescing step that keeps a 30-delta burst down
|
|
268
|
+
# to one or two real paints.
|
|
269
|
+
if self._pending_md_render and self.buffer != snapshot:
|
|
270
|
+
self._pending_md_render = False
|
|
271
|
+
# Cooldown: let the UI thread drain scroll / input
|
|
272
|
+
# events before we kick off another expensive parse.
|
|
273
|
+
try:
|
|
274
|
+
await asyncio.sleep(self._MD_RENDER_COOLDOWN_SEC)
|
|
275
|
+
except asyncio.CancelledError:
|
|
276
|
+
raise
|
|
277
|
+
continue
|
|
278
|
+
self.update(result)
|
|
279
|
+
break
|
|
280
|
+
finally:
|
|
281
|
+
if self._md_render_task is asyncio.current_task():
|
|
282
|
+
self._md_render_task = None
|
|
283
|
+
self._pending_md_render = False
|
|
284
|
+
|
|
285
|
+
def finalize_render(self) -> None:
|
|
286
|
+
"""Cancel any pending async render and paint the final markdown sync.
|
|
287
|
+
|
|
288
|
+
Called by ChatPane when the stream closes. Guarantees the last
|
|
289
|
+
frame the user sees is the authoritative markdown of the full
|
|
290
|
+
buffer — no flicker between the last async render and the bubble
|
|
291
|
+
being considered "done".
|
|
292
|
+
"""
|
|
293
|
+
if self._md_render_task is not None:
|
|
294
|
+
self._md_render_task.cancel()
|
|
295
|
+
self._md_render_task = None
|
|
296
|
+
self._pending_md_render = False
|
|
297
|
+
if self.role == "assistant" and self.buffer:
|
|
298
|
+
try:
|
|
299
|
+
self.update(self._compose_renderable())
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
136
303
|
def on_resize(self, event) -> None:
|
|
137
304
|
"""Re-render the assistant bubble so markdown wrap follows width.
|
|
138
305
|
|
|
@@ -141,10 +308,10 @@ class ChatMessageWidget(Static):
|
|
|
141
308
|
making the terminal wider) would leave wrap decisions stale.
|
|
142
309
|
"""
|
|
143
310
|
if self.role == "assistant" and self.buffer:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
311
|
+
# Width changed → schedule a fresh render at the new width.
|
|
312
|
+
# Goes through the async path so the (potentially expensive)
|
|
313
|
+
# re-layout doesn't stall the resize animation itself.
|
|
314
|
+
self._schedule_markdown_render()
|
|
148
315
|
|
|
149
316
|
|
|
150
317
|
def _markdown_to_text(raw: str, width: int = 100) -> Text:
|
|
@@ -205,9 +372,13 @@ class ChatPane(VerticalScroll):
|
|
|
205
372
|
}
|
|
206
373
|
"""
|
|
207
374
|
|
|
208
|
-
# Debounce window for content-delta flushing (seconds).
|
|
209
|
-
#
|
|
210
|
-
|
|
375
|
+
# Debounce window for content-delta flushing (seconds). 10 Hz is
|
|
376
|
+
# already visually fluid for streaming text (humans read at far
|
|
377
|
+
# less) and it halves the GIL contention between the UI thread and
|
|
378
|
+
# the off-thread Rich.Markdown parse compared to a 20 Hz flush —
|
|
379
|
+
# which is the difference that matters once an assistant reply
|
|
380
|
+
# crosses a few KB and each parse pass itself takes ~100 ms.
|
|
381
|
+
DEBOUNCE_SEC: float = 0.1
|
|
211
382
|
|
|
212
383
|
def __init__(self) -> None:
|
|
213
384
|
super().__init__()
|
|
@@ -217,6 +388,17 @@ class ChatPane(VerticalScroll):
|
|
|
217
388
|
self._tool_widgets: dict[str, ChatMessageWidget] = {}
|
|
218
389
|
|
|
219
390
|
def on_mount(self) -> None:
|
|
391
|
+
# Engage Textual's anchor: the compositor will auto-scroll this
|
|
392
|
+
# container to the bottom whenever its virtual size grows, without
|
|
393
|
+
# us enqueuing a ``scroll_end`` after every delta / tool event.
|
|
394
|
+
# (a) kills the ``call_after_refresh`` backlog that piled up when
|
|
395
|
+
# the UI thread was busy rendering markdown; (b) releases the anchor
|
|
396
|
+
# when the user manually scrolls up, so they can read history
|
|
397
|
+
# mid-stream without the viewport snapping back every 50 ms; and
|
|
398
|
+
# (c) re-engages automatically when they return to the bottom via
|
|
399
|
+
# ``_check_anchor``. Matches Textual's own "streaming Markdown"
|
|
400
|
+
# recipe (see ``Markdown.get_stream`` docstring).
|
|
401
|
+
self.anchor()
|
|
220
402
|
# Periodic flush; cheap because the reactive watcher already
|
|
221
403
|
# debounces repaints when buffer doesn't actually change.
|
|
222
404
|
self.set_interval(self.DEBOUNCE_SEC, self._flush_pending_delta)
|
|
@@ -227,12 +409,19 @@ class ChatPane(VerticalScroll):
|
|
|
227
409
|
self._close_active_assistant()
|
|
228
410
|
self._active_assistant = None
|
|
229
411
|
self.mount(ChatMessageWidget(role="user", initial=text))
|
|
230
|
-
|
|
412
|
+
# Force-scroll on user-initiated events even if the anchor was
|
|
413
|
+
# released (user scrolled up to read history, then hit Enter).
|
|
414
|
+
# ``immediate=True`` bypasses ``call_after_refresh`` so we don't
|
|
415
|
+
# queue behind a busy render pipeline. Textual's scroll_end also
|
|
416
|
+
# clears ``_anchor_released`` when ``_anchored`` is set.
|
|
417
|
+
self.scroll_end(immediate=True, animate=False)
|
|
231
418
|
|
|
232
419
|
def add_system_message(self, text: str) -> None:
|
|
233
420
|
self._close_active_assistant()
|
|
234
421
|
self.mount(ChatMessageWidget(role="system", initial=text))
|
|
235
|
-
|
|
422
|
+
# No forced scroll — anchor handles auto-follow when engaged; if
|
|
423
|
+
# the user scrolled up to read older content, a passive system
|
|
424
|
+
# line shouldn't yank them back to the bottom.
|
|
236
425
|
|
|
237
426
|
def add_renderable(
|
|
238
427
|
self,
|
|
@@ -271,14 +460,14 @@ class ChatPane(VerticalScroll):
|
|
|
271
460
|
wrapper.mount(widget)
|
|
272
461
|
else:
|
|
273
462
|
self.mount(widget)
|
|
274
|
-
|
|
463
|
+
# Anchor handles follow-on scrolling; no explicit scroll here.
|
|
275
464
|
|
|
276
465
|
def start_assistant_message(self) -> None:
|
|
277
466
|
"""Open a new assistant message to accumulate deltas into."""
|
|
278
467
|
self._close_active_assistant()
|
|
279
468
|
self._active_assistant = ChatMessageWidget(role="assistant", initial="")
|
|
280
469
|
self.mount(self._active_assistant)
|
|
281
|
-
|
|
470
|
+
# Anchor handles scrolling as the bubble fills; no explicit scroll.
|
|
282
471
|
|
|
283
472
|
def append_assistant_delta(self, delta: str) -> None:
|
|
284
473
|
"""Accumulate content into the active assistant message (debounced)."""
|
|
@@ -289,17 +478,22 @@ class ChatPane(VerticalScroll):
|
|
|
289
478
|
def finalize_assistant_message(self, final: str | None = None) -> None:
|
|
290
479
|
"""Flush any buffered delta and close the message."""
|
|
291
480
|
self._flush_pending_delta()
|
|
292
|
-
|
|
293
|
-
|
|
481
|
+
widget = self._active_assistant
|
|
482
|
+
if widget is not None:
|
|
483
|
+
if final is not None:
|
|
484
|
+
widget.buffer = final
|
|
485
|
+
# Cancel any in-flight async markdown render and paint the
|
|
486
|
+
# authoritative final markdown synchronously, so the last
|
|
487
|
+
# frame the user sees is the finished bubble — no flash
|
|
488
|
+
# between the async render that was mid-flight and the close.
|
|
489
|
+
widget.finalize_render()
|
|
294
490
|
self._active_assistant = None
|
|
295
|
-
self.scroll_end(animate=False)
|
|
296
491
|
|
|
297
492
|
def add_tool_call(self, *, tool_id: str, label: str) -> None:
|
|
298
493
|
"""Emit an inline 'in-progress' tool entry."""
|
|
299
494
|
widget = ChatMessageWidget(role="tool", initial=label, tool_state="pending")
|
|
300
495
|
self._tool_widgets[tool_id] = widget
|
|
301
496
|
self.mount(widget)
|
|
302
|
-
self.scroll_end(animate=False)
|
|
303
497
|
|
|
304
498
|
def complete_tool_call(
|
|
305
499
|
self, *, tool_id: str, label: str | None = None, duration_ms: float = 0.0
|
|
@@ -312,7 +506,6 @@ class ChatPane(VerticalScroll):
|
|
|
312
506
|
role="tool", initial=(label or tool_id), tool_state="done"
|
|
313
507
|
)
|
|
314
508
|
self.mount(widget)
|
|
315
|
-
self.scroll_end(animate=False)
|
|
316
509
|
return
|
|
317
510
|
# Update label if caller gave a richer one; flip state classes.
|
|
318
511
|
if label:
|
|
@@ -333,8 +526,13 @@ class ChatPane(VerticalScroll):
|
|
|
333
526
|
self._active_assistant.buffer + self._pending_delta
|
|
334
527
|
)
|
|
335
528
|
self._pending_delta = ""
|
|
336
|
-
self.scroll_end(animate=False)
|
|
337
529
|
|
|
338
530
|
def _close_active_assistant(self) -> None:
|
|
339
531
|
self._flush_pending_delta()
|
|
532
|
+
if self._active_assistant is not None:
|
|
533
|
+
# Finalize the previous bubble before losing the reference —
|
|
534
|
+
# otherwise its async render task may linger and try to
|
|
535
|
+
# update an orphaned widget (no visible failure, just wasted
|
|
536
|
+
# work and a lingering warning in the debug log).
|
|
537
|
+
self._active_assistant.finalize_render()
|
|
340
538
|
self._active_assistant = None
|
aru_code-0.37.0/aru/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.37.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
|
|
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
|