aru-code 0.41.0__tar.gz → 0.44.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.41.0/aru_code.egg-info → aru_code-0.44.0}/PKG-INFO +1 -1
- aru_code-0.44.0/aru/__init__.py +1 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/app.py +131 -0
- aru_code-0.44.0/aru/tui/sanitize.py +76 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/choice.py +20 -3
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/confirm.py +4 -1
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/text_input.py +4 -1
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/chat.py +262 -30
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/inline_choice.py +10 -3
- {aru_code-0.41.0 → aru_code-0.44.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/SOURCES.txt +1 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/pyproject.toml +1 -1
- aru_code-0.41.0/aru/__init__.py +0 -1
- {aru_code-0.41.0 → aru_code-0.44.0}/LICENSE +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/README.md +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/agent_factory.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/agents/base.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/agents/planner.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/cache_patch.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/checkpoints.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/cli.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/commands.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/completers.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/config.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/context.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/display.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/events.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/format/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/format/manager.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/format/runner.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/history_blocks.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/lsp/client.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/memory/loader.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/memory/store.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/permissions.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/providers.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/runner.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/runtime.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/select.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/session.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/sinks.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/streaming.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tool_policy.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/registry.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/search.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/shell.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/skill.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/web.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/ui.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru/ui.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/setup.cfg +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_catalog.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_codebase.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_config.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_context.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_delegate.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_format.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_lsp.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_main.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_memory.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_permissions.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_plugins.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_providers.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_ranker.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_runtime.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_select.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_permission_flow.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_worktree.py +0 -0
- {aru_code-0.41.0 → aru_code-0.44.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.44.0"
|
|
@@ -256,6 +256,13 @@ class AruApp(App):
|
|
|
256
256
|
"skills", "agents", "commands", "mcp", "yolo",
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
# Layer 10 — interval (seconds) between belt-and-suspenders re-emits of
|
|
260
|
+
# the mouse-tracking enable sequences. 8s is the worst-case time-to-recover
|
|
261
|
+
# if a leaked DEC private-mode escape disables the wheel mid-turn; the
|
|
262
|
+
# cost is ~24 bytes per tick (four idempotent SGR sequences). See
|
|
263
|
+
# ``on_mount`` and ``_reenable_mouse_tracking`` for context.
|
|
264
|
+
_MOUSE_REENABLE_INTERVAL: float = 8.0
|
|
265
|
+
|
|
259
266
|
def __init__(
|
|
260
267
|
self,
|
|
261
268
|
*,
|
|
@@ -375,6 +382,21 @@ class AruApp(App):
|
|
|
375
382
|
if not self.is_headless:
|
|
376
383
|
_push_terminal_title()
|
|
377
384
|
_set_terminal_title(_compose_terminal_title(self.session))
|
|
385
|
+
# Layer 10 / 11 self-heal — periodic recovery of terminal state and
|
|
386
|
+
# input focus. Two failure classes share one tick:
|
|
387
|
+
# * mouse-enable lost (leaked DEC private-mode escape disabled the
|
|
388
|
+
# wheel) — re-emit ``_enable_mouse_support`` (Layer 10).
|
|
389
|
+
# * input focus / visibility lost (a focusable panel mounted by
|
|
390
|
+
# ``add_renderable`` grabbed focus, or an ``InlineChoicePrompt``
|
|
391
|
+
# left ``#input.-hidden`` stuck because its callback raised) —
|
|
392
|
+
# reassert the prompt as focused-and-visible (Layer 11).
|
|
393
|
+
# Both checks are idempotent on a healthy app and skipped under
|
|
394
|
+
# headless tests where there's no live driver to talk to.
|
|
395
|
+
if not self.is_headless:
|
|
396
|
+
self.set_interval(
|
|
397
|
+
self._MOUSE_REENABLE_INTERVAL,
|
|
398
|
+
self._self_heal_terminal_state,
|
|
399
|
+
)
|
|
378
400
|
|
|
379
401
|
def _replay_resumed_history(self, chat: ChatPane) -> None:
|
|
380
402
|
"""Render a resumed session's user/assistant text back into the chat.
|
|
@@ -1068,6 +1090,115 @@ class AruApp(App):
|
|
|
1068
1090
|
self.query_one(ContextPane).refresh_from_session()
|
|
1069
1091
|
except Exception:
|
|
1070
1092
|
pass
|
|
1093
|
+
# Layer 9 self-heal — re-assert Textual's mouse-tracking
|
|
1094
|
+
# sequences at the turn boundary. See ``_reenable_mouse_tracking``
|
|
1095
|
+
# for the rationale; here we eagerly recover the moment the
|
|
1096
|
+
# turn ends so the user's first post-turn scroll always works,
|
|
1097
|
+
# without waiting for the periodic Layer 10 tick.
|
|
1098
|
+
self._reenable_mouse_tracking()
|
|
1099
|
+
|
|
1100
|
+
def _reenable_mouse_tracking(self) -> None:
|
|
1101
|
+
"""Re-emit Textual's mouse-tracking enable sequences (idempotent).
|
|
1102
|
+
|
|
1103
|
+
Calls the active driver's ``_enable_mouse_support`` which writes
|
|
1104
|
+
four short SGR sequences (``?1000h`` / ``?1003h`` / ``?1015h`` /
|
|
1105
|
+
``?1006h``). They re-arm X10 mouse reporting at the terminal level
|
|
1106
|
+
so a leaked ``\\x1b[?1000l`` (or any other DEC private-mode escape
|
|
1107
|
+
that disabled wheel input) is recovered from automatically.
|
|
1108
|
+
Idempotent — sending an enable when tracking is already on is a
|
|
1109
|
+
documented no-op on every emulator.
|
|
1110
|
+
Called from two sites:
|
|
1111
|
+
* ``_run_turn`` finally-clause (Layer 9) — eager recovery at every
|
|
1112
|
+
turn boundary so the first post-turn scroll always works.
|
|
1113
|
+
* ``_self_heal_terminal_state`` periodic tick (Layer 10) — recovers
|
|
1114
|
+
mid-turn corruption (e.g. a leaked escape in panel content)
|
|
1115
|
+
within ``_MOUSE_REENABLE_INTERVAL`` instead of leaving the wheel
|
|
1116
|
+
dead until the (potentially long) turn finishes.
|
|
1117
|
+
Wrapped in ``try/except`` because the driver may be ``None`` in
|
|
1118
|
+
headless / test mode and the private method's exact name could
|
|
1119
|
+
shift in a future Textual; better to no-op silently than crash.
|
|
1120
|
+
"""
|
|
1121
|
+
try:
|
|
1122
|
+
driver = self._driver
|
|
1123
|
+
if driver is not None:
|
|
1124
|
+
driver._enable_mouse_support()
|
|
1125
|
+
except Exception:
|
|
1126
|
+
pass
|
|
1127
|
+
|
|
1128
|
+
def _self_heal_terminal_state(self) -> None:
|
|
1129
|
+
"""Periodic recovery of mouse tracking and input focus (Layers 10 + 11).
|
|
1130
|
+
|
|
1131
|
+
Two failure classes that the tick recovers from:
|
|
1132
|
+
|
|
1133
|
+
1. **Terminal mouse-tracking lost.** Layer 9 already re-enables at
|
|
1134
|
+
the turn boundary; this catches mid-turn corruption so the
|
|
1135
|
+
wheel comes back within ``_MOUSE_REENABLE_INTERVAL`` instead
|
|
1136
|
+
of waiting for the agent to finish.
|
|
1137
|
+
2. **Input prompt invisible or unfocused** when nothing else
|
|
1138
|
+
legitimately owns it. Three concrete scenarios this fixes:
|
|
1139
|
+
* an ``InlineChoicePrompt`` callback raised before
|
|
1140
|
+
``on_unmount`` ran, leaving ``#input.-hidden`` stuck;
|
|
1141
|
+
* a focusable panel mounted by ``add_renderable`` (pre-Layer-11
|
|
1142
|
+
behaviour) grabbed focus and never released it;
|
|
1143
|
+
* an exception during ``finalize_assistant_message`` cancelled
|
|
1144
|
+
a focus-restore that ``_run_turn`` would normally do.
|
|
1145
|
+
|
|
1146
|
+
We only intervene when **no modal is on top** (modal owns input,
|
|
1147
|
+
``len(self.screen_stack) <= 1``) and **no ``InlineChoicePrompt``
|
|
1148
|
+
is currently mounted** (the inline prompt legitimately steals
|
|
1149
|
+
focus and hides the input by design — touching it mid-flight
|
|
1150
|
+
would steal back from the user). When both conditions hold, we
|
|
1151
|
+
treat the input as the canonical focus target.
|
|
1152
|
+
"""
|
|
1153
|
+
# Layer 10 — mouse tracking.
|
|
1154
|
+
self._reenable_mouse_tracking()
|
|
1155
|
+
|
|
1156
|
+
# Layer 11 — input watchdog. Skip if a modal is on top: the modal
|
|
1157
|
+
# is the legitimate input owner and the underlying ``Input`` is
|
|
1158
|
+
# not part of the active focus chain.
|
|
1159
|
+
try:
|
|
1160
|
+
if len(self.screen_stack) > 1:
|
|
1161
|
+
return
|
|
1162
|
+
except Exception:
|
|
1163
|
+
return
|
|
1164
|
+
|
|
1165
|
+
# Skip if an ``InlineChoicePrompt`` is currently mounted: it has
|
|
1166
|
+
# explicitly hidden the input and owns the focus while waiting
|
|
1167
|
+
# for the user's choice. ``query`` returns an empty list when the
|
|
1168
|
+
# widget tree has no match, so the truth-test is safe.
|
|
1169
|
+
try:
|
|
1170
|
+
from aru.tui.widgets.inline_choice import InlineChoicePrompt
|
|
1171
|
+
if list(self.query(InlineChoicePrompt)):
|
|
1172
|
+
return
|
|
1173
|
+
except Exception:
|
|
1174
|
+
pass
|
|
1175
|
+
|
|
1176
|
+
# Recover ``#input`` if it's stuck hidden (the ``-hidden`` class
|
|
1177
|
+
# comes off only inside ``InlineChoicePrompt._toggle_input``; if
|
|
1178
|
+
# that didn't run because the callback raised, the user is
|
|
1179
|
+
# stranded with no visible prompt). ``remove_class`` on a class
|
|
1180
|
+
# that isn't applied is a no-op, so the unconditional call is safe.
|
|
1181
|
+
try:
|
|
1182
|
+
inp = self.query_one(Input)
|
|
1183
|
+
except Exception:
|
|
1184
|
+
return
|
|
1185
|
+
try:
|
|
1186
|
+
if inp.has_class("-hidden"):
|
|
1187
|
+
inp.remove_class("-hidden")
|
|
1188
|
+
except Exception:
|
|
1189
|
+
pass
|
|
1190
|
+
|
|
1191
|
+
# Re-focus only when *nothing* currently has focus. We deliberately
|
|
1192
|
+
# do NOT yank focus away from a sidebar / scrollback / search
|
|
1193
|
+
# screen the user navigated to themselves — that would fight
|
|
1194
|
+
# legitimate keyboard navigation. The ``focused is None`` guard
|
|
1195
|
+
# narrows the recovery to the ghost-focus state we actually
|
|
1196
|
+
# observed in the bug.
|
|
1197
|
+
try:
|
|
1198
|
+
if self.screen.focused is None:
|
|
1199
|
+
inp.focus()
|
|
1200
|
+
except Exception:
|
|
1201
|
+
pass
|
|
1071
1202
|
|
|
1072
1203
|
# ── Bus wiring — ToolsPane + StatusPane subscribe to plugin events ──
|
|
1073
1204
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Terminal-output hygiene helpers used across the TUI.
|
|
2
|
+
|
|
3
|
+
Layer 7 / Layer 9 / Layer 10 of the scroll-freeze post-mortem (see
|
|
4
|
+
``aru/tui/widgets/chat.py`` for the full history) all hinge on the same
|
|
5
|
+
invariant: **no string content originating from the agent, a tool, or
|
|
6
|
+
arbitrary file content may reach the terminal with raw C0 control bytes
|
|
7
|
+
intact.** A single stray ``\\x1b[?1000l`` switches X10 mouse reporting off
|
|
8
|
+
on Windows ConPTY (and most modern emulators), at which point the wheel
|
|
9
|
+
stops working in every scroll surface in the app simultaneously.
|
|
10
|
+
|
|
11
|
+
The chat pane already protects everything that flows through
|
|
12
|
+
``ChatMessageWidget.buffer`` and the ``add_renderable`` path, but modal
|
|
13
|
+
screens (``ChoiceModal`` / ``ConfirmModal`` / ``TextInputModal``)
|
|
14
|
+
historically built their ``Label`` / ``Static`` content from raw
|
|
15
|
+
agent-provided strings — including the diff preview shown when an edit
|
|
16
|
+
needs approval. Files containing escape bytes (colored scripts, captured
|
|
17
|
+
terminal output, accidentally-saved binaries) flowed straight through to
|
|
18
|
+
the terminal. Hence Layer 10: lift these helpers out of ``chat.py`` and
|
|
19
|
+
apply them at every modal composition point too.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# C0 controls (0x00-0x1F) and DEL (0x7F) are dropped on the way to the
|
|
28
|
+
# terminal, EXCEPT ``\n`` and ``\t`` which carry semantic meaning to
|
|
29
|
+
# markdown-it, Rich, and Textual layout. Implementation note:
|
|
30
|
+
# ``str.translate`` is implemented in C and runs in microseconds for
|
|
31
|
+
# multi-KB inputs — applying it at every boundary is essentially free.
|
|
32
|
+
_CTRL_CHAR_TRANSLATION: dict[int, None] = {
|
|
33
|
+
c: None for c in range(32) if chr(c) not in ("\n", "\t")
|
|
34
|
+
}
|
|
35
|
+
_CTRL_CHAR_TRANSLATION[0x7F] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sanitize_for_terminal(raw: str) -> str:
|
|
39
|
+
"""Remove non-printable C0 controls so rogue ANSI escapes can't reach the tty.
|
|
40
|
+
|
|
41
|
+
Keeps ``\\n`` and ``\\t``; drops everything else in the C0 range plus DEL.
|
|
42
|
+
Apply this at every boundary where externally-sourced text becomes a
|
|
43
|
+
Rich renderable / Textual widget content.
|
|
44
|
+
"""
|
|
45
|
+
return raw.translate(_CTRL_CHAR_TRANSLATION)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SanitizedRenderable:
|
|
49
|
+
"""Wraps a Rich renderable so its output segments are stripped of C0 bytes.
|
|
50
|
+
|
|
51
|
+
``sanitize_for_terminal`` covers every plain string we render. Arbitrary
|
|
52
|
+
Rich renderables (panels, diff previews, plan summaries, the startup
|
|
53
|
+
logo) skip that path and would mount as ``Static(renderable)`` whose
|
|
54
|
+
segments hit the compositor unmodified — including any rogue escapes
|
|
55
|
+
embedded in the inner text.
|
|
56
|
+
|
|
57
|
+
This wrapper closes that gap at the segment level: ``console.render``
|
|
58
|
+
yields segments from the inner renderable, we strip C0 bytes from any
|
|
59
|
+
segment whose ``.text`` contains them, and re-emit the cleaned stream.
|
|
60
|
+
Rich's ``Segment`` is a ``NamedTuple`` so ``seg._replace(text=...)`` is
|
|
61
|
+
a cheap immutable swap. Unchanged segments are re-emitted unmodified —
|
|
62
|
+
the hot path is a single ``str.translate`` per segment which typically
|
|
63
|
+
no-ops.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, inner: Any) -> None:
|
|
67
|
+
self._inner = inner
|
|
68
|
+
|
|
69
|
+
def __rich_console__(self, console: Any, options: Any) -> Any:
|
|
70
|
+
for seg in console.render(self._inner, options):
|
|
71
|
+
if seg.text:
|
|
72
|
+
clean = sanitize_for_terminal(seg.text)
|
|
73
|
+
if clean != seg.text:
|
|
74
|
+
yield seg._replace(text=clean)
|
|
75
|
+
continue
|
|
76
|
+
yield seg
|
|
@@ -12,6 +12,8 @@ from textual.screen import ModalScreen
|
|
|
12
12
|
from textual.widgets import Label, OptionList, Static
|
|
13
13
|
from textual.widgets.option_list import Option
|
|
14
14
|
|
|
15
|
+
from aru.tui.sanitize import SanitizedRenderable, sanitize_for_terminal
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class ChoiceModal(ModalScreen[int | None]):
|
|
17
19
|
"""Numbered option menu. ``dismiss(int)`` returns the chosen index.
|
|
@@ -74,12 +76,27 @@ class ChoiceModal(ModalScreen[int | None]):
|
|
|
74
76
|
def compose(self) -> ComposeResult:
|
|
75
77
|
with Vertical(id="choice-box"):
|
|
76
78
|
if self._title:
|
|
77
|
-
|
|
79
|
+
# Sanitise: title may include agent-generated text (plan name,
|
|
80
|
+
# rejection reason, tool label) which can carry C0 escapes
|
|
81
|
+
# that would disable mouse tracking globally — see Layer 10
|
|
82
|
+
# in the chat.py post-mortem.
|
|
83
|
+
yield Label(
|
|
84
|
+
sanitize_for_terminal(self._title),
|
|
85
|
+
id="choice-title",
|
|
86
|
+
)
|
|
78
87
|
if self._details is not None:
|
|
79
|
-
|
|
88
|
+
# ``details`` is the diff preview / plan summary panel.
|
|
89
|
+
# Diffs over file content readily contain escape bytes when
|
|
90
|
+
# the file does (colored scripts, captured terminal output),
|
|
91
|
+
# making this the most likely entry point for the bug
|
|
92
|
+
# the periodic re-enable timer recovers from.
|
|
93
|
+
yield Static(
|
|
94
|
+
SanitizedRenderable(self._details),
|
|
95
|
+
id="choice-details",
|
|
96
|
+
)
|
|
80
97
|
yield OptionList(
|
|
81
98
|
*[
|
|
82
|
-
Option(label, id=str(i))
|
|
99
|
+
Option(sanitize_for_terminal(label), id=str(i))
|
|
83
100
|
for i, label in enumerate(self._options)
|
|
84
101
|
],
|
|
85
102
|
id="choice-options",
|
|
@@ -8,6 +8,8 @@ from textual.containers import Horizontal, Vertical
|
|
|
8
8
|
from textual.screen import ModalScreen
|
|
9
9
|
from textual.widgets import Button, Label
|
|
10
10
|
|
|
11
|
+
from aru.tui.sanitize import sanitize_for_terminal
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
class ConfirmModal(ModalScreen[bool]):
|
|
13
15
|
"""Yes / No dialog. ``dismiss(True)`` on yes, ``dismiss(False)`` on no.
|
|
@@ -53,7 +55,8 @@ class ConfirmModal(ModalScreen[bool]):
|
|
|
53
55
|
|
|
54
56
|
def compose(self) -> ComposeResult:
|
|
55
57
|
with Vertical(id="confirm-box"):
|
|
56
|
-
|
|
58
|
+
# Strip C0 controls — see Layer 10 in chat.py post-mortem.
|
|
59
|
+
yield Label(sanitize_for_terminal(self._prompt), id="confirm-prompt")
|
|
57
60
|
with Horizontal(id="confirm-buttons"):
|
|
58
61
|
yield Button(
|
|
59
62
|
"Yes",
|
|
@@ -8,6 +8,8 @@ from textual.containers import Vertical
|
|
|
8
8
|
from textual.screen import ModalScreen
|
|
9
9
|
from textual.widgets import Input, Label
|
|
10
10
|
|
|
11
|
+
from aru.tui.sanitize import sanitize_for_terminal
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
class TextInputModal(ModalScreen[str | None]):
|
|
13
15
|
"""Single-line text prompt. ``dismiss(str)`` on Enter, ``dismiss(None)``
|
|
@@ -49,7 +51,8 @@ class TextInputModal(ModalScreen[str | None]):
|
|
|
49
51
|
|
|
50
52
|
def compose(self) -> ComposeResult:
|
|
51
53
|
with Vertical(id="text-box"):
|
|
52
|
-
|
|
54
|
+
# Strip C0 controls — see Layer 10 in chat.py post-mortem.
|
|
55
|
+
yield Label(sanitize_for_terminal(self._prompt), id="text-prompt")
|
|
53
56
|
yield Input(
|
|
54
57
|
value=self._default,
|
|
55
58
|
placeholder=self._placeholder,
|
|
@@ -128,6 +128,223 @@ cost and layout cost on the Aru side. This bug is about the
|
|
|
128
128
|
terminal's own private-mode state being corrupted from outside —
|
|
129
129
|
entirely unrelated to how fast we render, and invisible to any
|
|
130
130
|
latency benchmark. Treat as a seventh layer: output-hygiene.
|
|
131
|
+
|
|
132
|
+
----
|
|
133
|
+
|
|
134
|
+
Post-mortem — "mouse wheel dead during heavy streaming" (2026-04-23,
|
|
135
|
+
``fix/scroll-refinement``)
|
|
136
|
+
---------------------------------------------------------------------
|
|
137
|
+
**Symptom:** mouse wheel over the ChatPane does nothing while the
|
|
138
|
+
agent is actively streaming / running tool batches. TAB to focus
|
|
139
|
+
the pane + arrow keys / PgUp / PgDn works fine. Asymmetric enough
|
|
140
|
+
that it felt like "the mouse lost focus" — not a freeze.
|
|
141
|
+
|
|
142
|
+
Reported against session ``final-fantasy-battle/.aru/sessions/
|
|
143
|
+
e9397dc3.json``.
|
|
144
|
+
|
|
145
|
+
**Original (incorrect) theory:** interaction between
|
|
146
|
+
``self.anchor()`` (layer 4) and ``_scroll_up_for_pointer``. This
|
|
147
|
+
was based on a misreading of Textual's source — see the Layer 9
|
|
148
|
+
correction below. ``_scroll_up_for_pointer`` does *not* pass
|
|
149
|
+
``release_anchor=False``; it defaults to ``True`` (``widget.py:3378``
|
|
150
|
+
→ ``widget.py:2730``), so wheel-up already releases the anchor via
|
|
151
|
+
the framework. The ``on_mouse_scroll_up`` handler we added
|
|
152
|
+
(``ChatPane.on_mouse_scroll_up``) is therefore redundant with the
|
|
153
|
+
framework's own behaviour — a no-op on the happy path. It is kept
|
|
154
|
+
as defensive redundancy because removing it is the same shape of
|
|
155
|
+
change as keeping it, but it should not be credited for "fixing"
|
|
156
|
+
anything.
|
|
157
|
+
|
|
158
|
+
**What the bug probably was:** the same Layer-7 class of issue
|
|
159
|
+
that the next session surfaced again — a rogue DEC private-mode
|
|
160
|
+
escape reaching the terminal and disabling X10 mouse reporting.
|
|
161
|
+
See Layer 9 for the real signature and the robust fix.
|
|
162
|
+
|
|
163
|
+
----
|
|
164
|
+
|
|
165
|
+
Post-mortem — "wheel globally dead at end of stream" (2026-04-24,
|
|
166
|
+
``fix/scroll-refinement`` continued)
|
|
167
|
+
---------------------------------------------------------------------
|
|
168
|
+
**Symptom:** immediately after a long streaming turn concluded,
|
|
169
|
+
mouse wheel stopped working on *every* scrollable surface in the
|
|
170
|
+
app — ChatPane, sidebars, modals — simultaneously. TAB to walk
|
|
171
|
+
focus into a scrollbar and arrow-key scrolling from there worked.
|
|
172
|
+
Classic Layer-7 fingerprint: terminal-level mouse reporting got
|
|
173
|
+
turned off.
|
|
174
|
+
|
|
175
|
+
Reported against session ``final-fantasy-battle3/.aru/sessions/
|
|
176
|
+
7e9e4549.json``: one mega-turn with 120 tool calls interleaved
|
|
177
|
+
with 66 text blocks, 31 plan-panel mounts via
|
|
178
|
+
``add_renderable(scrollable=True)``, ~245 widgets in the pane.
|
|
179
|
+
|
|
180
|
+
**What we could prove:** a byte-level scan of the saved session
|
|
181
|
+
for C0 control chars turned up zero ``\\x1b`` bytes. The leak is
|
|
182
|
+
either (a) from a path that isn't persisted to ``session.json``
|
|
183
|
+
(tool ``stdout``/``stderr`` never reaches the chat directly but
|
|
184
|
+
transient UI strings, skill output, or reasoning tokens might),
|
|
185
|
+
or (b) a Windows ConPTY quirk during high-volume redraw where the
|
|
186
|
+
driver's mouse-enable state drops without us emitting anything
|
|
187
|
+
hostile. Chasing the exact source is caça ao fantasma; the
|
|
188
|
+
mitigation is structural.
|
|
189
|
+
|
|
190
|
+
**Two-prong fix:**
|
|
191
|
+
|
|
192
|
+
1. **Close the last unsanitised content path —
|
|
193
|
+
``_SanitizedRenderable``.** ``ChatMessageWidget`` already
|
|
194
|
+
sanitises everything that goes through its ``buffer``. Arbitrary
|
|
195
|
+
Rich renderables handed to ``add_renderable`` (plan panels, task
|
|
196
|
+
lists, diff previews, the logo) bypass that path and mount as
|
|
197
|
+
``Static(renderable)``. The wrapper sits between the renderable
|
|
198
|
+
and Rich's console, filtering C0 bytes out of every segment's
|
|
199
|
+
``.text`` before it reaches Textual's compositor. Matches the
|
|
200
|
+
Layer 7 sanitisation boundary for the unchecked route.
|
|
201
|
+
|
|
202
|
+
2. **Self-heal at turn boundary —
|
|
203
|
+
``AruApp._run_turn`` finally clause.** Call the driver's
|
|
204
|
+
``_enable_mouse_support()`` after each turn finishes. That
|
|
205
|
+
re-emits Textual's own four mouse-enable sequences (``?1000h``,
|
|
206
|
+
``?1003h``, ``?1015h``, ``?1006h`` — see
|
|
207
|
+
``textual/drivers/windows_driver.py:56``). Cost is four short
|
|
208
|
+
writes; benefit is full recovery of wheel input regardless of
|
|
209
|
+
what corrupted the terminal state mid-turn. Idempotent: a no-op
|
|
210
|
+
when mouse tracking was never disabled.
|
|
211
|
+
|
|
212
|
+
Treat as a ninth layer: defence-in-depth against terminal-state
|
|
213
|
+
corruption. Prong 1 plugs the last known-possible leak inside our
|
|
214
|
+
code; prong 2 recovers even if something outside our reach drops
|
|
215
|
+
the state anyway.
|
|
216
|
+
|
|
217
|
+
----
|
|
218
|
+
|
|
219
|
+
Post-mortem — "wheel still dies after edits / option prompts" (2026-04-24,
|
|
220
|
+
``fix/scroll-analysis`` continued)
|
|
221
|
+
---------------------------------------------------------------------
|
|
222
|
+
**Symptom:** users reported that the wheel-dead bug reproduces most
|
|
223
|
+
often **right after a file edit was approved** or **while picking an
|
|
224
|
+
option in a modal**, rather than only at end-of-turn. Layers 7 + 9
|
|
225
|
+
already cover ``ChatMessageWidget.buffer`` and ``add_renderable``,
|
|
226
|
+
but the bug clearly fired through some content path neither of those
|
|
227
|
+
guarded.
|
|
228
|
+
|
|
229
|
+
**Cause:** the Layer 9 audit had a blind spot — the *modal screens*.
|
|
230
|
+
``ChoiceModal`` (the approval prompt for plan / edit / permission),
|
|
231
|
+
``ConfirmModal``, and ``TextInputModal`` all built their visible
|
|
232
|
+
content from raw caller-supplied strings:
|
|
233
|
+
|
|
234
|
+
* ``aru/tui/screens/choice.py:77`` — ``Label(self._title)``
|
|
235
|
+
* ``aru/tui/screens/choice.py:79`` — ``Static(self._details)``
|
|
236
|
+
(the **diff preview** for edit approvals)
|
|
237
|
+
* ``aru/tui/screens/confirm.py:56`` — ``Label(self._prompt)``
|
|
238
|
+
* ``aru/tui/screens/text_input.py:52`` — ``Label(self._prompt)``
|
|
239
|
+
|
|
240
|
+
The ``details`` panel of ``ChoiceModal`` is the obvious gun — it's
|
|
241
|
+
where the unified diff goes when the user is asked to approve an
|
|
242
|
+
``edit_file``. Diffs over file content faithfully reproduce whatever
|
|
243
|
+
bytes were in the file; a colored shell script, a captured terminal
|
|
244
|
+
recording, or any binary-ish artifact saved as text trivially carries
|
|
245
|
+
``\\x1b[?1000l`` straight into the diff and onto the terminal. That
|
|
246
|
+
matches the user's reported pattern exactly: "wheel dies after I
|
|
247
|
+
approve an edit".
|
|
248
|
+
|
|
249
|
+
**Two-prong fix:**
|
|
250
|
+
|
|
251
|
+
1. **Lift the Layer 7/9 helpers into a shared module.**
|
|
252
|
+
``aru/tui/sanitize.py`` now exports ``sanitize_for_terminal`` and
|
|
253
|
+
``SanitizedRenderable``; ``chat.py`` imports them with the same
|
|
254
|
+
names it had locally so nothing inside this file changes
|
|
255
|
+
semantically. The four modal compose sites now apply the same
|
|
256
|
+
barrier — ``Label(sanitize_for_terminal(self._prompt))`` for
|
|
257
|
+
plain-text prompts and ``Static(SanitizedRenderable(self._details))``
|
|
258
|
+
for arbitrary renderables. Any future modal added to the TUI
|
|
259
|
+
should follow this convention; the helper module is the canonical
|
|
260
|
+
location.
|
|
261
|
+
|
|
262
|
+
2. **Periodic mouse-tracking re-emit (``AruApp._reenable_mouse_tracking``).**
|
|
263
|
+
The Layer 9 turn-boundary recovery only fires after the agent
|
|
264
|
+
finishes. A diff preview shown mid-turn can disable the wheel for
|
|
265
|
+
minutes if the agent is doing a long batch of edits. The new
|
|
266
|
+
``set_interval(_MOUSE_REENABLE_INTERVAL=8s, ...)`` in
|
|
267
|
+
``on_mount`` re-emits the four enable sequences every eight
|
|
268
|
+
seconds regardless of turn state — ~24 bytes per tick, idempotent
|
|
269
|
+
on a healthy terminal. Worst-case time-to-recover is bounded at 8s
|
|
270
|
+
instead of "until the agent stops working".
|
|
271
|
+
|
|
272
|
+
Treat as a tenth layer. Layer 10 differs from 9 in scope: 9 plugs
|
|
273
|
+
known leaks at known boundaries, 10 assumes leaks will keep being
|
|
274
|
+
found (Textual stack, plugin renderables, a future modal) and recovers
|
|
275
|
+
on a clock independently of any code path noticing. The pair is the
|
|
276
|
+
intended steady state — not a bug-of-the-week, a structural answer to
|
|
277
|
+
a class of bug we cannot fully prevent without rewriting how arbitrary
|
|
278
|
+
renderables reach Rich's console.
|
|
279
|
+
|
|
280
|
+
----
|
|
281
|
+
|
|
282
|
+
Post-mortem — "input loses focus mid-stream in YOLO" (2026-04-25,
|
|
283
|
+
``fix/scroll-analysis`` continued)
|
|
284
|
+
---------------------------------------------------------------------
|
|
285
|
+
**Symptom:** during long YOLO-mode runs (no permission prompts, no
|
|
286
|
+
modals), the user reported that the input box stops accepting
|
|
287
|
+
keystrokes mid-implementation. Often coincident with the wheel-dead
|
|
288
|
+
signature, but distinct: typing goes nowhere even before any visible
|
|
289
|
+
panel suggests focus moved.
|
|
290
|
+
|
|
291
|
+
**What it was not:** the Layer 10 audit was scoped to *terminal-state*
|
|
292
|
+
corruption (mouse tracking turned off by stray escape bytes). The
|
|
293
|
+
focus issue is a separate failure mode — the same content paths that
|
|
294
|
+
leak C0 bytes also mount focusable widgets, and Textual's default
|
|
295
|
+
focus chain happily includes them.
|
|
296
|
+
|
|
297
|
+
**Two compounding causes:**
|
|
298
|
+
|
|
299
|
+
1. **``add_renderable(scrollable=True)`` mounted a focus-eligible
|
|
300
|
+
``VerticalScroll``.** ``VerticalScroll.can_focus`` defaults to
|
|
301
|
+
``True`` so users can Tab into a panel for keyboard scrolling.
|
|
302
|
+
Inside the chat flow, where every plan/task/diff render adds a
|
|
303
|
+
wrapper, this turns content panels into focus competitors with the
|
|
304
|
+
``Input``. A single Tab during streaming, a focus restoration
|
|
305
|
+
after a modal closes, or any Textual-internal focus rotation could
|
|
306
|
+
land on a panel and leave the input dead. Fix: ``wrapper.can_focus
|
|
307
|
+
= False`` on every scrollable wrapper. Mouse-wheel scrolling inside
|
|
308
|
+
the panel still works because Textual routes wheel events via the
|
|
309
|
+
pointer, not the focus chain.
|
|
310
|
+
|
|
311
|
+
2. **``InlineChoicePrompt`` had no recovery if its callback raised.**
|
|
312
|
+
The widget hides ``#input`` on mount and restores it on unmount.
|
|
313
|
+
If the ``on_choice`` callback throws (or the widget is removed by a
|
|
314
|
+
parent before lifecycle fires), ``-hidden`` stays applied and the
|
|
315
|
+
input is invisible until the next mount/unmount cycle — possibly
|
|
316
|
+
never. Layer 11 doesn't fix the underlying lifecycle issue
|
|
317
|
+
(callbacks should be exception-safe in their own right) but adds a
|
|
318
|
+
recovery loop: the periodic tick checks for stuck state and clears
|
|
319
|
+
it.
|
|
320
|
+
|
|
321
|
+
**Layer 11 — input watchdog, sharing the Layer 10 timer.**
|
|
322
|
+
``AruApp._self_heal_terminal_state`` extends ``_reenable_mouse_tracking``
|
|
323
|
+
to also enforce input invariants when the inline-prompt path is not
|
|
324
|
+
legitimately active:
|
|
325
|
+
|
|
326
|
+
* If a modal screen is on top → skip (modal owns input).
|
|
327
|
+
* If an ``InlineChoicePrompt`` is mounted → skip (it owns focus by
|
|
328
|
+
design while waiting for a choice).
|
|
329
|
+
* Otherwise: clear stuck ``-hidden`` from ``#input`` and refocus it
|
|
330
|
+
iff ``screen.focused is None``. The ``focused is None`` guard is
|
|
331
|
+
intentional — it does NOT fight legitimate Tab navigation to the
|
|
332
|
+
sidebar / scrollback / search screen. We only recover from the
|
|
333
|
+
ghost-focus state where Textual's chain has nobody.
|
|
334
|
+
|
|
335
|
+
Layer 11 also extends the modal-sanitisation pattern to
|
|
336
|
+
``InlineChoicePrompt``, which had been overlooked by Layer 10 — its
|
|
337
|
+
``Label(self._title)`` / ``Option(label)`` calls now go through
|
|
338
|
+
``sanitize_for_terminal`` before reaching the widget tree.
|
|
339
|
+
|
|
340
|
+
**Why we keep adding layers and not rewriting the architecture:** each
|
|
341
|
+
layer addresses a distinct *signal* the user reported — and each one
|
|
342
|
+
has narrow, idempotent recovery semantics. Rewriting the chat to use
|
|
343
|
+
a single virtualised text buffer (à la Textual's recent Markdown
|
|
344
|
+
virtualisation experiments) would close some of these by structure,
|
|
345
|
+
but at the cost of every other property the chat currently has
|
|
346
|
+
(selection, copy, mid-stream insertion of arbitrary Rich panels, plan
|
|
347
|
+
mounts). Layered defences are cheap and additive; the rewrite is not.
|
|
131
348
|
"""
|
|
132
349
|
|
|
133
350
|
from __future__ import annotations
|
|
@@ -148,6 +365,11 @@ from textual.containers import VerticalScroll
|
|
|
148
365
|
from textual.reactive import reactive
|
|
149
366
|
from textual.widgets import Static
|
|
150
367
|
|
|
368
|
+
from aru.tui.sanitize import (
|
|
369
|
+
SanitizedRenderable as _SanitizedRenderable,
|
|
370
|
+
sanitize_for_terminal as _sanitize_for_terminal,
|
|
371
|
+
)
|
|
372
|
+
|
|
151
373
|
|
|
152
374
|
# Reference-definition line: ``[label]: href`` (optional leading 0–3 spaces).
|
|
153
375
|
# Presence of *any* reference definition anywhere in the snapshot disables the
|
|
@@ -157,31 +379,6 @@ from textual.widgets import Static
|
|
|
157
379
|
_REF_DEF_RE = re.compile(r"^[ ]{0,3}\[[^\]\n]+\]:\s", re.MULTILINE)
|
|
158
380
|
|
|
159
381
|
|
|
160
|
-
# Strip ASCII control characters (0x00–0x1F plus DEL 0x7F) from any content
|
|
161
|
-
# about to be rendered to the terminal, EXCEPT ``\n`` and ``\t`` which are
|
|
162
|
-
# semantically meaningful to markdown-it, Rich, and Textual. Rich's ``Text``
|
|
163
|
-
# passes escape bytes through verbatim, so if a streamed model reply or a
|
|
164
|
-
# tool label contains ``\x1b[?1000l`` (or any other DEC private-mode escape),
|
|
165
|
-
# the terminal receives it directly and globally disables mouse tracking —
|
|
166
|
-
# at which point every scroll area in the TUI stops responding to the
|
|
167
|
-
# mouse wheel (keyboard still works, which is why the bug presents as
|
|
168
|
-
# "only scroll froze"). Models that talk about terminal control sequences
|
|
169
|
-
# or tools that echo subprocess output are the realistic injection paths.
|
|
170
|
-
_CTRL_CHAR_TRANSLATION = {c: None for c in range(32) if chr(c) not in ("\n", "\t")}
|
|
171
|
-
_CTRL_CHAR_TRANSLATION[0x7F] = None
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def _sanitize_for_terminal(raw: str) -> str:
|
|
175
|
-
"""Remove non-printable control chars so rogue ANSI escapes can't reach the tty.
|
|
176
|
-
|
|
177
|
-
Keeps ``\\n`` and ``\\t``; drops everything else in the C0 range plus DEL.
|
|
178
|
-
Cheap: ``str.translate`` is implemented in C and runs in microseconds for
|
|
179
|
-
multi-KB inputs. Applied at every boundary where chat content becomes a
|
|
180
|
-
Rich renderable.
|
|
181
|
-
"""
|
|
182
|
-
return raw.translate(_CTRL_CHAR_TRANSLATION)
|
|
183
|
-
|
|
184
|
-
|
|
185
382
|
def _scan_fences(text: str) -> tuple[int, int]:
|
|
186
383
|
"""One-pass fence scanner. Returns ``(last_stable_split, open_fence_start)``.
|
|
187
384
|
|
|
@@ -875,16 +1072,36 @@ class ChatPane(VerticalScroll):
|
|
|
875
1072
|
# us enqueuing a ``scroll_end`` after every delta / tool event.
|
|
876
1073
|
# (a) kills the ``call_after_refresh`` backlog that piled up when
|
|
877
1074
|
# the UI thread was busy rendering markdown; (b) releases the anchor
|
|
878
|
-
# when the user manually scrolls
|
|
879
|
-
#
|
|
880
|
-
# (c) re-engages automatically when they return to the bottom
|
|
881
|
-
# ``_check_anchor``. Matches Textual's own "streaming Markdown"
|
|
1075
|
+
# when the user manually scrolls — wheel, keyboard, or drag all go
|
|
1076
|
+
# through ``_scroll_to`` which releases by default (widget.py:2730);
|
|
1077
|
+
# and (c) re-engages automatically when they return to the bottom
|
|
1078
|
+
# via ``_check_anchor``. Matches Textual's own "streaming Markdown"
|
|
882
1079
|
# recipe (see ``Markdown.get_stream`` docstring).
|
|
883
1080
|
self.anchor()
|
|
884
1081
|
# Periodic flush; cheap because the reactive watcher already
|
|
885
1082
|
# debounces repaints when buffer doesn't actually change.
|
|
886
1083
|
self.set_interval(self.DEBOUNCE_SEC, self._flush_pending_delta)
|
|
887
1084
|
|
|
1085
|
+
def on_mouse_scroll_up(self, event) -> None:
|
|
1086
|
+
"""Defensive redundancy — explicitly release the anchor on wheel-up.
|
|
1087
|
+
|
|
1088
|
+
Originally added under a misreading of Textual's source (see the
|
|
1089
|
+
Layer 8 correction in the module post-mortem). The framework's
|
|
1090
|
+
``_scroll_up_for_pointer`` calls ``_scroll_to`` *without*
|
|
1091
|
+
``release_anchor``, which defaults to ``True`` in
|
|
1092
|
+
``widget.py:2730`` — so Textual already releases the anchor on
|
|
1093
|
+
wheel-up. This handler does the same thing one beat earlier and
|
|
1094
|
+
is effectively a no-op on the normal path.
|
|
1095
|
+
|
|
1096
|
+
Kept because (a) removing it has the same shape of change as
|
|
1097
|
+
keeping it and (b) if some future Textual refactor ever flips
|
|
1098
|
+
the default, this keeps wheel-up behaving the way ChatPane
|
|
1099
|
+
needs. No ``event.stop()`` — the framework handler still runs
|
|
1100
|
+
after this and does the actual scroll.
|
|
1101
|
+
"""
|
|
1102
|
+
if self._anchored and not self._anchor_released:
|
|
1103
|
+
self.release_anchor()
|
|
1104
|
+
|
|
888
1105
|
# ── API used by TextualBusSink and the App ────────────────────────
|
|
889
1106
|
|
|
890
1107
|
def add_user_message(self, text: str) -> None:
|
|
@@ -924,7 +1141,11 @@ class ChatPane(VerticalScroll):
|
|
|
924
1141
|
"""
|
|
925
1142
|
from textual.widgets import Static
|
|
926
1143
|
self._close_active_assistant()
|
|
927
|
-
|
|
1144
|
+
# Sanitise the renderable's segment stream — see
|
|
1145
|
+
# ``_SanitizedRenderable`` docstring. This is the only content path
|
|
1146
|
+
# into the ChatPane that doesn't go through ``ChatMessageWidget``,
|
|
1147
|
+
# so it needs its own Layer-7 barrier.
|
|
1148
|
+
widget = Static(_SanitizedRenderable(renderable))
|
|
928
1149
|
if scrollable:
|
|
929
1150
|
from textual.containers import VerticalScroll
|
|
930
1151
|
wrapper = VerticalScroll()
|
|
@@ -938,6 +1159,17 @@ class ChatPane(VerticalScroll):
|
|
|
938
1159
|
# row bleeding outside the box.
|
|
939
1160
|
wrapper.styles.padding = 0
|
|
940
1161
|
wrapper.styles.margin = 0
|
|
1162
|
+
# ``VerticalScroll`` is focusable by default so users can Tab
|
|
1163
|
+
# into it for keyboard scrolling. Inside the chat flow that's
|
|
1164
|
+
# the wrong default — the user navigates via the outer
|
|
1165
|
+
# ``ChatPane`` (whole-conversation scroll) and the wheel works
|
|
1166
|
+
# over an inner panel without focus thanks to Textual's
|
|
1167
|
+
# pointer-based wheel routing. Leaving these focusable makes
|
|
1168
|
+
# them race the ``Input`` for focus during plan/task/diff
|
|
1169
|
+
# mounts, with the symptom that typing stops reaching the
|
|
1170
|
+
# prompt mid-stream. ``can_focus = False`` removes them from
|
|
1171
|
+
# the focus chain entirely. (Layer 11 in the chat.py post-mortem.)
|
|
1172
|
+
wrapper.can_focus = False
|
|
941
1173
|
self.mount(wrapper)
|
|
942
1174
|
wrapper.mount(widget)
|
|
943
1175
|
else:
|