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.
Files changed (183) hide show
  1. {aru_code-0.37.0/aru_code.egg-info → aru_code-0.38.0}/PKG-INFO +1 -1
  2. aru_code-0.38.0/aru/__init__.py +1 -0
  3. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/chat.py +217 -19
  4. {aru_code-0.37.0 → aru_code-0.38.0/aru_code.egg-info}/PKG-INFO +1 -1
  5. {aru_code-0.37.0 → aru_code-0.38.0}/pyproject.toml +1 -1
  6. aru_code-0.37.0/aru/__init__.py +0 -1
  7. {aru_code-0.37.0 → aru_code-0.38.0}/LICENSE +0 -0
  8. {aru_code-0.37.0 → aru_code-0.38.0}/README.md +0 -0
  9. {aru_code-0.37.0 → aru_code-0.38.0}/aru/agent_factory.py +0 -0
  10. {aru_code-0.37.0 → aru_code-0.38.0}/aru/agents/__init__.py +0 -0
  11. {aru_code-0.37.0 → aru_code-0.38.0}/aru/agents/base.py +0 -0
  12. {aru_code-0.37.0 → aru_code-0.38.0}/aru/agents/catalog.py +0 -0
  13. {aru_code-0.37.0 → aru_code-0.38.0}/aru/agents/planner.py +0 -0
  14. {aru_code-0.37.0 → aru_code-0.38.0}/aru/cache_patch.py +0 -0
  15. {aru_code-0.37.0 → aru_code-0.38.0}/aru/checkpoints.py +0 -0
  16. {aru_code-0.37.0 → aru_code-0.38.0}/aru/cli.py +0 -0
  17. {aru_code-0.37.0 → aru_code-0.38.0}/aru/commands.py +0 -0
  18. {aru_code-0.37.0 → aru_code-0.38.0}/aru/completers.py +0 -0
  19. {aru_code-0.37.0 → aru_code-0.38.0}/aru/config.py +0 -0
  20. {aru_code-0.37.0 → aru_code-0.38.0}/aru/context.py +0 -0
  21. {aru_code-0.37.0 → aru_code-0.38.0}/aru/display.py +0 -0
  22. {aru_code-0.37.0 → aru_code-0.38.0}/aru/events.py +0 -0
  23. {aru_code-0.37.0 → aru_code-0.38.0}/aru/format/__init__.py +0 -0
  24. {aru_code-0.37.0 → aru_code-0.38.0}/aru/format/manager.py +0 -0
  25. {aru_code-0.37.0 → aru_code-0.38.0}/aru/format/runner.py +0 -0
  26. {aru_code-0.37.0 → aru_code-0.38.0}/aru/history_blocks.py +0 -0
  27. {aru_code-0.37.0 → aru_code-0.38.0}/aru/lsp/__init__.py +0 -0
  28. {aru_code-0.37.0 → aru_code-0.38.0}/aru/lsp/client.py +0 -0
  29. {aru_code-0.37.0 → aru_code-0.38.0}/aru/lsp/manager.py +0 -0
  30. {aru_code-0.37.0 → aru_code-0.38.0}/aru/lsp/protocol.py +0 -0
  31. {aru_code-0.37.0 → aru_code-0.38.0}/aru/memory/__init__.py +0 -0
  32. {aru_code-0.37.0 → aru_code-0.38.0}/aru/memory/extractor.py +0 -0
  33. {aru_code-0.37.0 → aru_code-0.38.0}/aru/memory/loader.py +0 -0
  34. {aru_code-0.37.0 → aru_code-0.38.0}/aru/memory/store.py +0 -0
  35. {aru_code-0.37.0 → aru_code-0.38.0}/aru/permissions.py +0 -0
  36. {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugin_cache.py +0 -0
  37. {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/__init__.py +0 -0
  38. {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/custom_tools.py +0 -0
  39. {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/hooks.py +0 -0
  40. {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/manager.py +0 -0
  41. {aru_code-0.37.0 → aru_code-0.38.0}/aru/plugins/tool_api.py +0 -0
  42. {aru_code-0.37.0 → aru_code-0.38.0}/aru/providers.py +0 -0
  43. {aru_code-0.37.0 → aru_code-0.38.0}/aru/runner.py +0 -0
  44. {aru_code-0.37.0 → aru_code-0.38.0}/aru/runtime.py +0 -0
  45. {aru_code-0.37.0 → aru_code-0.38.0}/aru/select.py +0 -0
  46. {aru_code-0.37.0 → aru_code-0.38.0}/aru/session.py +0 -0
  47. {aru_code-0.37.0 → aru_code-0.38.0}/aru/sinks.py +0 -0
  48. {aru_code-0.37.0 → aru_code-0.38.0}/aru/streaming.py +0 -0
  49. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tool_policy.py +0 -0
  50. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/__init__.py +0 -0
  51. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/_diff.py +0 -0
  52. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/_shared.py +0 -0
  53. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/apply_patch.py +0 -0
  54. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/apply_patch_prompt.txt +0 -0
  55. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/ast_tools.py +0 -0
  56. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/codebase.py +0 -0
  57. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/delegate.py +0 -0
  58. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/delegate_prompt.txt +0 -0
  59. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/file_ops.py +0 -0
  60. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/gitignore.py +0 -0
  61. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/lsp.py +0 -0
  62. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/mcp_client.py +0 -0
  63. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/memory_tool.py +0 -0
  64. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/plan_mode.py +0 -0
  65. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/ranker.py +0 -0
  66. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/registry.py +0 -0
  67. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/search.py +0 -0
  68. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/shell.py +0 -0
  69. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/skill.py +0 -0
  70. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/tasklist.py +0 -0
  71. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/web.py +0 -0
  72. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tools/worktree.py +0 -0
  73. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/__init__.py +0 -0
  74. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/app.py +0 -0
  75. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/__init__.py +0 -0
  76. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/choice.py +0 -0
  77. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/confirm.py +0 -0
  78. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/search.py +0 -0
  79. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/screens/text_input.py +0 -0
  80. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/sinks.py +0 -0
  81. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/slash_bridge.py +0 -0
  82. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/ui.py +0 -0
  83. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/__init__.py +0 -0
  84. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/completer.py +0 -0
  85. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/context_pane.py +0 -0
  86. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/header.py +0 -0
  87. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/inline_choice.py +0 -0
  88. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/loaded_pane.py +0 -0
  89. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/status.py +0 -0
  90. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/thinking.py +0 -0
  91. {aru_code-0.37.0 → aru_code-0.38.0}/aru/tui/widgets/tools.py +0 -0
  92. {aru_code-0.37.0 → aru_code-0.38.0}/aru/ui.py +0 -0
  93. {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/SOURCES.txt +0 -0
  94. {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/dependency_links.txt +0 -0
  95. {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/entry_points.txt +0 -0
  96. {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/requires.txt +0 -0
  97. {aru_code-0.37.0 → aru_code-0.38.0}/aru_code.egg-info/top_level.txt +0 -0
  98. {aru_code-0.37.0 → aru_code-0.38.0}/setup.cfg +0 -0
  99. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_agents_base.py +0 -0
  100. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_agents_md_coverage.py +0 -0
  101. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_apply_patch.py +0 -0
  102. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_async_tool_permission.py +0 -0
  103. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cache_patch_metrics.py +0 -0
  104. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cache_patch_stop_reason.py +0 -0
  105. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_catalog.py +0 -0
  106. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_chat_scrollable.py +0 -0
  107. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_checkpoints.py +0 -0
  108. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli.py +0 -0
  109. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_advanced.py +0 -0
  110. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_base.py +0 -0
  111. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_completers.py +0 -0
  112. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_new.py +0 -0
  113. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_run_cli.py +0 -0
  114. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_session.py +0 -0
  115. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cli_shell.py +0 -0
  116. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_codebase.py +0 -0
  117. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_confabulation_regression.py +0 -0
  118. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_config.py +0 -0
  119. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_context.py +0 -0
  120. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_context_pane.py +0 -0
  121. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_cwd_awareness.py +0 -0
  122. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_delegate.py +0 -0
  123. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_events_backward_compat.py +0 -0
  124. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_events_schema.py +0 -0
  125. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_fork_ctx_concurrency.py +0 -0
  126. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_format.py +0 -0
  127. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_gitignore.py +0 -0
  128. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_guardrails_scenarios.py +0 -0
  129. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_invoke_skill.py +0 -0
  130. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_invoked_skills.py +0 -0
  131. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_loaded_pane_path.py +0 -0
  132. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_lsp.py +0 -0
  133. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_lsp_rename.py +0 -0
  134. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_main.py +0 -0
  135. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_markdown_to_text.py +0 -0
  136. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_mcp_client.py +0 -0
  137. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_mcp_health.py +0 -0
  138. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_memory.py +0 -0
  139. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_memory_tool.py +0 -0
  140. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_microcompact.py +0 -0
  141. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_permissions.py +0 -0
  142. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plan_mode_refactor.py +0 -0
  143. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plugin_cache.py +0 -0
  144. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plugin_errors.py +0 -0
  145. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plugin_hooks_v2.py +0 -0
  146. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_plugins.py +0 -0
  147. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_providers.py +0 -0
  148. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_ranker.py +0 -0
  149. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_reasoning.py +0 -0
  150. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_runner_interrupt.py +0 -0
  151. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_runner_recovery.py +0 -0
  152. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_runtime.py +0 -0
  153. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_select.py +0 -0
  154. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_skill_disallowed_tools.py +0 -0
  155. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_status_breakdown.py +0 -0
  156. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_status_cost.py +0 -0
  157. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_streaming_sink.py +0 -0
  158. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tasklist.py +0 -0
  159. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_thread_tool_timeout.py +0 -0
  160. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tool_policy.py +0 -0
  161. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_truncation_marker.py +0 -0
  162. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_app_boot.py +0 -0
  163. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_bindings.py +0 -0
  164. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_bus_flow.py +0 -0
  165. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_chat.py +0 -0
  166. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_completer.py +0 -0
  167. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_completer_dynamic.py +0 -0
  168. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_copy.py +0 -0
  169. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_input_behaviour.py +0 -0
  170. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_mention_expand.py +0 -0
  171. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_modals.py +0 -0
  172. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_mode_cycle.py +0 -0
  173. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_native_selection.py +0 -0
  174. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_permission_flow.py +0 -0
  175. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_plan_task_render.py +0 -0
  176. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_sidebar_toggle.py +0 -0
  177. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_slash_bridge.py +0 -0
  178. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_snapshot_smoke.py +0 -0
  179. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_thinking_and_boot.py +0 -0
  180. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_tui_widgets_visual.py +0 -0
  181. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_ui_adapter.py +0 -0
  182. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_worktree.py +0 -0
  183. {aru_code-0.37.0 → aru_code-0.38.0}/tests/test_worktree_session_restore.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.37.0
3
+ Version: 0.38.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.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.05, _flush)`` debounces rapid content deltas so we
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 the Static when the buffer changes.
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
- try:
145
- self.update(self._compose_renderable())
146
- except Exception:
147
- pass
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). Chosen so
209
- # 20+ tokens/s still looks smooth without burning CPU on every byte.
210
- DEBOUNCE_SEC: float = 0.05
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
- self.scroll_end(animate=False)
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
- self.scroll_end(animate=False)
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
- self.scroll_end(animate=False)
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
- self.scroll_end(animate=False)
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
- if self._active_assistant is not None and final is not None:
293
- self._active_assistant.buffer = final
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.37.0
3
+ Version: 0.38.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.37.0"
7
+ version = "0.38.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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