aru-code 0.42.0__tar.gz → 0.45.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.42.0/aru_code.egg-info → aru_code-0.45.0}/PKG-INFO +1 -1
- aru_code-0.45.0/aru/__init__.py +1 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/app.py +231 -14
- aru_code-0.45.0/aru/tui/sanitize.py +76 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/choice.py +20 -3
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/confirm.py +4 -1
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/text_input.py +4 -1
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/chat.py +226 -58
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/inline_choice.py +10 -3
- {aru_code-0.42.0 → aru_code-0.45.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/SOURCES.txt +2 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/pyproject.toml +1 -1
- aru_code-0.45.0/tests/test_tui_layer12_recovery.py +120 -0
- aru_code-0.42.0/aru/__init__.py +0 -1
- {aru_code-0.42.0 → aru_code-0.45.0}/LICENSE +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/README.md +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/agent_factory.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/agents/base.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/agents/planner.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/cache_patch.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/checkpoints.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/cli.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/commands.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/completers.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/config.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/context.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/display.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/events.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/format/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/format/manager.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/format/runner.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/history_blocks.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/lsp/client.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/memory/loader.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/memory/store.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/permissions.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/providers.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/runner.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/runtime.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/select.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/session.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/sinks.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/streaming.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tool_policy.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/registry.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/search.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/shell.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/skill.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/web.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/ui.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru/ui.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/setup.cfg +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_catalog.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_codebase.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_config.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_context.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_delegate.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_format.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_lsp.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_main.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_memory.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_permissions.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_plugins.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_providers.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_ranker.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_runtime.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_select.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_permission_flow.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_worktree.py +0 -0
- {aru_code-0.42.0 → aru_code-0.45.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.45.0"
|
|
@@ -32,6 +32,7 @@ from __future__ import annotations
|
|
|
32
32
|
|
|
33
33
|
import asyncio
|
|
34
34
|
import sys
|
|
35
|
+
import time
|
|
35
36
|
from typing import Any
|
|
36
37
|
|
|
37
38
|
from textual.app import App, ComposeResult
|
|
@@ -256,6 +257,25 @@ class AruApp(App):
|
|
|
256
257
|
"skills", "agents", "commands", "mcp", "yolo",
|
|
257
258
|
}
|
|
258
259
|
|
|
260
|
+
# Layer 10 / 12 — interval (seconds) between belt-and-suspenders re-emits
|
|
261
|
+
# of the mouse-tracking enable sequences. Was 8s pre-Layer-12; user
|
|
262
|
+
# report on 2026-04-25 against ``final-fantasy-9/.aru/sessions/b33dfb99``
|
|
263
|
+
# was that the wheel never came back even after the turn ended in YOLO,
|
|
264
|
+
# and 8s was visibly long enough that the user gave up before the next
|
|
265
|
+
# tick. 3s is short enough that a corrupted state self-heals before the
|
|
266
|
+
# next mouse interaction, and the cost is still ~64 bytes per tick (the
|
|
267
|
+
# Layer 12 off-then-on shake — see ``_reenable_mouse_tracking``).
|
|
268
|
+
_MOUSE_REENABLE_INTERVAL: float = 3.0
|
|
269
|
+
|
|
270
|
+
# Layer 12 — minimum interval (seconds) between keypress-triggered
|
|
271
|
+
# mouse-tracking re-arms. Each keystroke is an opportunity to recover
|
|
272
|
+
# — if the user is typing it might be precisely BECAUSE the wheel just
|
|
273
|
+
# stopped working — but we don't want a fast typist to turn every
|
|
274
|
+
# keystroke into four extra terminal writes. 500 ms is below human
|
|
275
|
+
# noticeable retry latency yet caps the keystroke→write amplification
|
|
276
|
+
# at ~2 Hz worst case.
|
|
277
|
+
_KEYPRESS_REARM_DEBOUNCE: float = 0.5
|
|
278
|
+
|
|
259
279
|
def __init__(
|
|
260
280
|
self,
|
|
261
281
|
*,
|
|
@@ -281,6 +301,12 @@ class AruApp(App):
|
|
|
281
301
|
# cleared) by on_input_submitted.
|
|
282
302
|
self._pending_paste: str | None = None
|
|
283
303
|
self._pending_paste_lines: int = 0
|
|
304
|
+
# Layer 12 — last time we re-emitted the mouse-tracking enable
|
|
305
|
+
# sequences via the keypress path. Used to debounce per-keystroke
|
|
306
|
+
# re-arming so a fast typist doesn't spam the terminal with re-
|
|
307
|
+
# enables. Initialised to negative infinity so the first keystroke
|
|
308
|
+
# always rearms.
|
|
309
|
+
self._last_mouse_reenable_at: float = float("-inf")
|
|
284
310
|
|
|
285
311
|
# ── Composition ──────────────────────────────────────────────────
|
|
286
312
|
|
|
@@ -375,6 +401,21 @@ class AruApp(App):
|
|
|
375
401
|
if not self.is_headless:
|
|
376
402
|
_push_terminal_title()
|
|
377
403
|
_set_terminal_title(_compose_terminal_title(self.session))
|
|
404
|
+
# Layer 10 / 11 self-heal — periodic recovery of terminal state and
|
|
405
|
+
# input focus. Two failure classes share one tick:
|
|
406
|
+
# * mouse-enable lost (leaked DEC private-mode escape disabled the
|
|
407
|
+
# wheel) — re-emit ``_enable_mouse_support`` (Layer 10).
|
|
408
|
+
# * input focus / visibility lost (a focusable panel mounted by
|
|
409
|
+
# ``add_renderable`` grabbed focus, or an ``InlineChoicePrompt``
|
|
410
|
+
# left ``#input.-hidden`` stuck because its callback raised) —
|
|
411
|
+
# reassert the prompt as focused-and-visible (Layer 11).
|
|
412
|
+
# Both checks are idempotent on a healthy app and skipped under
|
|
413
|
+
# headless tests where there's no live driver to talk to.
|
|
414
|
+
if not self.is_headless:
|
|
415
|
+
self.set_interval(
|
|
416
|
+
self._MOUSE_REENABLE_INTERVAL,
|
|
417
|
+
self._self_heal_terminal_state,
|
|
418
|
+
)
|
|
378
419
|
|
|
379
420
|
def _replay_resumed_history(self, chat: ChatPane) -> None:
|
|
380
421
|
"""Render a resumed session's user/assistant text back into the chat.
|
|
@@ -527,7 +568,14 @@ class AruApp(App):
|
|
|
527
568
|
suggestion and fires ``Input.Submitted``, which produced the
|
|
528
569
|
"three Enters to run /help" glitch. Tab is the only key that
|
|
529
570
|
accepts the highlighted suggestion.
|
|
571
|
+
|
|
572
|
+
Layer 12 — every keystroke is also a recovery opportunity. The
|
|
573
|
+
Layer 10 periodic tick still runs every ``_MOUSE_REENABLE_INTERVAL``
|
|
574
|
+
but a typing user wants the wheel back NOW, not in three seconds.
|
|
575
|
+
Debounced via ``_KEYPRESS_REARM_DEBOUNCE`` so a fast typist
|
|
576
|
+
doesn't amplify each keystroke into four extra terminal writes.
|
|
530
577
|
"""
|
|
578
|
+
self._maybe_rearm_mouse_on_keypress()
|
|
531
579
|
try:
|
|
532
580
|
completer = self.query_one(SlashCompleter)
|
|
533
581
|
except Exception:
|
|
@@ -1069,23 +1117,192 @@ class AruApp(App):
|
|
|
1069
1117
|
except Exception:
|
|
1070
1118
|
pass
|
|
1071
1119
|
# Layer 9 self-heal — re-assert Textual's mouse-tracking
|
|
1072
|
-
# sequences at
|
|
1073
|
-
#
|
|
1074
|
-
#
|
|
1075
|
-
#
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1120
|
+
# sequences at the turn boundary. See ``_reenable_mouse_tracking``
|
|
1121
|
+
# for the rationale; here we eagerly recover the moment the
|
|
1122
|
+
# turn ends so the user's first post-turn scroll always works,
|
|
1123
|
+
# without waiting for the periodic Layer 10 tick.
|
|
1124
|
+
self._reenable_mouse_tracking()
|
|
1125
|
+
|
|
1126
|
+
# Layer 12 — DEC private-mode sequences for mouse tracking. Defined
|
|
1127
|
+
# at class scope so both the off-then-on shake below and any future
|
|
1128
|
+
# caller (Click handler, focus event) can reuse the exact same set
|
|
1129
|
+
# without drift.
|
|
1130
|
+
_MOUSE_DISABLE_SEQS: tuple[str, ...] = (
|
|
1131
|
+
"\x1b[?1000l",
|
|
1132
|
+
"\x1b[?1003l",
|
|
1133
|
+
"\x1b[?1015l",
|
|
1134
|
+
"\x1b[?1006l",
|
|
1135
|
+
)
|
|
1136
|
+
_MOUSE_ENABLE_SEQS: tuple[str, ...] = (
|
|
1137
|
+
"\x1b[?1000h",
|
|
1138
|
+
"\x1b[?1003h",
|
|
1139
|
+
"\x1b[?1015h",
|
|
1140
|
+
"\x1b[?1006h",
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
def _reenable_mouse_tracking(self) -> None:
|
|
1144
|
+
"""Re-arm mouse tracking via an off-then-on shake (Layer 12).
|
|
1145
|
+
|
|
1146
|
+
Pre-Layer-12 this method delegated to the driver's
|
|
1147
|
+
``_enable_mouse_support`` which writes four short SGR sequences
|
|
1148
|
+
(``?1000h`` / ``?1003h`` / ``?1015h`` / ``?1006h``). That worked
|
|
1149
|
+
when the terminal forwarded the writes verbatim, but the user
|
|
1150
|
+
report on 2026-04-25 against
|
|
1151
|
+
``final-fantasy-9/.aru/sessions/b33dfb99`` was the wheel never
|
|
1152
|
+
coming back even though Layer 9 (turn-boundary call) and Layer 10
|
|
1153
|
+
(8s tick) both ran. Two failure modes the old code couldn't
|
|
1154
|
+
recover from:
|
|
1155
|
+
|
|
1156
|
+
1. **ConPTY enable cache.** Windows ConPTY tracks DEC private-mode
|
|
1157
|
+
state on its side and may treat ``?1000h`` as a no-op when its
|
|
1158
|
+
cache says "already enabled" — even when the underlying
|
|
1159
|
+
terminal lost the state. Sending ``?1000l`` first forces the
|
|
1160
|
+
cache through a state transition so the subsequent ``?1000h``
|
|
1161
|
+
is propagated.
|
|
1162
|
+
2. **Driver-side gate.** ``WindowsDriver._enable_mouse_support``
|
|
1163
|
+
opens with ``if not self._mouse: return`` (textual 8.2.4,
|
|
1164
|
+
windows_driver.py:55). If a future Textual flips the gate
|
|
1165
|
+
during shutdown / pause / alt-screen toggle, our recovery
|
|
1166
|
+
silently no-ops. Going through ``driver.write`` bypasses the
|
|
1167
|
+
gate and writes the bytes regardless.
|
|
1168
|
+
|
|
1169
|
+
Implementation: emit all four ``...l`` (off) sequences, then all
|
|
1170
|
+
four ``...h`` (on) sequences, then flush. Eight short writes
|
|
1171
|
+
(~64 bytes) bufferised into one terminal emit by ``WriterThread``.
|
|
1172
|
+
Idempotent on a healthy terminal — the off→on cycle leaves the
|
|
1173
|
+
final state identical to a single ``?1000h``, just with a
|
|
1174
|
+
microscopic gap during the transition (no observable wheel-event
|
|
1175
|
+
loss in practice).
|
|
1176
|
+
|
|
1177
|
+
Called from three sites:
|
|
1178
|
+
* ``_run_turn`` finally-clause (Layer 9) — eager recovery at every
|
|
1179
|
+
turn boundary so the first post-turn scroll always works.
|
|
1180
|
+
* ``_self_heal_terminal_state`` periodic tick (Layer 10) — recovers
|
|
1181
|
+
mid-turn corruption within ``_MOUSE_REENABLE_INTERVAL``.
|
|
1182
|
+
* ``on_key`` keypress trigger (Layer 12) — recovers the moment the
|
|
1183
|
+
user touches the keyboard, since a keystroke is a strong signal
|
|
1184
|
+
they noticed the wheel is dead.
|
|
1185
|
+
|
|
1186
|
+
Wrapped in ``try/except`` because the driver may be ``None`` in
|
|
1187
|
+
headless / test mode; we'd rather no-op silently than crash.
|
|
1188
|
+
"""
|
|
1189
|
+
try:
|
|
1190
|
+
driver = self._driver
|
|
1191
|
+
if driver is None:
|
|
1192
|
+
return
|
|
1193
|
+
for seq in self._MOUSE_DISABLE_SEQS:
|
|
1194
|
+
try:
|
|
1195
|
+
driver.write(seq)
|
|
1196
|
+
except Exception:
|
|
1197
|
+
pass
|
|
1198
|
+
for seq in self._MOUSE_ENABLE_SEQS:
|
|
1199
|
+
try:
|
|
1200
|
+
driver.write(seq)
|
|
1201
|
+
except Exception:
|
|
1202
|
+
pass
|
|
1083
1203
|
try:
|
|
1084
|
-
driver
|
|
1085
|
-
if driver is not None:
|
|
1086
|
-
driver._enable_mouse_support()
|
|
1204
|
+
driver.flush()
|
|
1087
1205
|
except Exception:
|
|
1088
1206
|
pass
|
|
1207
|
+
except Exception:
|
|
1208
|
+
pass
|
|
1209
|
+
|
|
1210
|
+
def _maybe_rearm_mouse_on_keypress(self) -> None:
|
|
1211
|
+
"""Layer 12 — re-arm mouse tracking on each keystroke (debounced).
|
|
1212
|
+
|
|
1213
|
+
Trigger fires from ``on_key`` so any user keypress is treated as a
|
|
1214
|
+
recovery opportunity. A typing user is the strongest signal we
|
|
1215
|
+
have that the wheel just stopped working — they reached for the
|
|
1216
|
+
keyboard because the mouse stopped responding, or they're about
|
|
1217
|
+
to scroll back with PgUp and want it ready. Either way, paying
|
|
1218
|
+
~64 bytes per keypress (capped at 2 Hz by ``_KEYPRESS_REARM_DEBOUNCE``)
|
|
1219
|
+
is a trivial cost for sub-second recovery latency.
|
|
1220
|
+
|
|
1221
|
+
The debounce intentionally uses ``time.monotonic`` rather than the
|
|
1222
|
+
Textual scheduler so it survives across the async ``on_key``
|
|
1223
|
+
boundary without an extra task. ``-inf`` initial value guarantees
|
|
1224
|
+
the first keystroke always rearms.
|
|
1225
|
+
"""
|
|
1226
|
+
now = time.monotonic()
|
|
1227
|
+
if now - self._last_mouse_reenable_at < self._KEYPRESS_REARM_DEBOUNCE:
|
|
1228
|
+
return
|
|
1229
|
+
self._last_mouse_reenable_at = now
|
|
1230
|
+
self._reenable_mouse_tracking()
|
|
1231
|
+
|
|
1232
|
+
def _self_heal_terminal_state(self) -> None:
|
|
1233
|
+
"""Periodic recovery of mouse tracking and input focus (Layers 10 + 11).
|
|
1234
|
+
|
|
1235
|
+
Two failure classes that the tick recovers from:
|
|
1236
|
+
|
|
1237
|
+
1. **Terminal mouse-tracking lost.** Layer 9 already re-enables at
|
|
1238
|
+
the turn boundary; this catches mid-turn corruption so the
|
|
1239
|
+
wheel comes back within ``_MOUSE_REENABLE_INTERVAL`` instead
|
|
1240
|
+
of waiting for the agent to finish.
|
|
1241
|
+
2. **Input prompt invisible or unfocused** when nothing else
|
|
1242
|
+
legitimately owns it. Three concrete scenarios this fixes:
|
|
1243
|
+
* an ``InlineChoicePrompt`` callback raised before
|
|
1244
|
+
``on_unmount`` ran, leaving ``#input.-hidden`` stuck;
|
|
1245
|
+
* a focusable panel mounted by ``add_renderable`` (pre-Layer-11
|
|
1246
|
+
behaviour) grabbed focus and never released it;
|
|
1247
|
+
* an exception during ``finalize_assistant_message`` cancelled
|
|
1248
|
+
a focus-restore that ``_run_turn`` would normally do.
|
|
1249
|
+
|
|
1250
|
+
We only intervene when **no modal is on top** (modal owns input,
|
|
1251
|
+
``len(self.screen_stack) <= 1``) and **no ``InlineChoicePrompt``
|
|
1252
|
+
is currently mounted** (the inline prompt legitimately steals
|
|
1253
|
+
focus and hides the input by design — touching it mid-flight
|
|
1254
|
+
would steal back from the user). When both conditions hold, we
|
|
1255
|
+
treat the input as the canonical focus target.
|
|
1256
|
+
"""
|
|
1257
|
+
# Layer 10 — mouse tracking.
|
|
1258
|
+
self._reenable_mouse_tracking()
|
|
1259
|
+
|
|
1260
|
+
# Layer 11 — input watchdog. Skip if a modal is on top: the modal
|
|
1261
|
+
# is the legitimate input owner and the underlying ``Input`` is
|
|
1262
|
+
# not part of the active focus chain.
|
|
1263
|
+
try:
|
|
1264
|
+
if len(self.screen_stack) > 1:
|
|
1265
|
+
return
|
|
1266
|
+
except Exception:
|
|
1267
|
+
return
|
|
1268
|
+
|
|
1269
|
+
# Skip if an ``InlineChoicePrompt`` is currently mounted: it has
|
|
1270
|
+
# explicitly hidden the input and owns the focus while waiting
|
|
1271
|
+
# for the user's choice. ``query`` returns an empty list when the
|
|
1272
|
+
# widget tree has no match, so the truth-test is safe.
|
|
1273
|
+
try:
|
|
1274
|
+
from aru.tui.widgets.inline_choice import InlineChoicePrompt
|
|
1275
|
+
if list(self.query(InlineChoicePrompt)):
|
|
1276
|
+
return
|
|
1277
|
+
except Exception:
|
|
1278
|
+
pass
|
|
1279
|
+
|
|
1280
|
+
# Recover ``#input`` if it's stuck hidden (the ``-hidden`` class
|
|
1281
|
+
# comes off only inside ``InlineChoicePrompt._toggle_input``; if
|
|
1282
|
+
# that didn't run because the callback raised, the user is
|
|
1283
|
+
# stranded with no visible prompt). ``remove_class`` on a class
|
|
1284
|
+
# that isn't applied is a no-op, so the unconditional call is safe.
|
|
1285
|
+
try:
|
|
1286
|
+
inp = self.query_one(Input)
|
|
1287
|
+
except Exception:
|
|
1288
|
+
return
|
|
1289
|
+
try:
|
|
1290
|
+
if inp.has_class("-hidden"):
|
|
1291
|
+
inp.remove_class("-hidden")
|
|
1292
|
+
except Exception:
|
|
1293
|
+
pass
|
|
1294
|
+
|
|
1295
|
+
# Re-focus only when *nothing* currently has focus. We deliberately
|
|
1296
|
+
# do NOT yank focus away from a sidebar / scrollback / search
|
|
1297
|
+
# screen the user navigated to themselves — that would fight
|
|
1298
|
+
# legitimate keyboard navigation. The ``focused is None`` guard
|
|
1299
|
+
# narrows the recovery to the ghost-focus state we actually
|
|
1300
|
+
# observed in the bug.
|
|
1301
|
+
try:
|
|
1302
|
+
if self.screen.focused is None:
|
|
1303
|
+
inp.focus()
|
|
1304
|
+
except Exception:
|
|
1305
|
+
pass
|
|
1089
1306
|
|
|
1090
1307
|
# ── Bus wiring — ToolsPane + StatusPane subscribe to plugin events ──
|
|
1091
1308
|
|
|
@@ -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,
|