aru-code 0.42.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.42.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.42.0 → aru_code-0.44.0}/aru/tui/app.py +130 -17
- aru_code-0.44.0/aru/tui/sanitize.py +76 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/choice.py +20 -3
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/confirm.py +4 -1
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/text_input.py +4 -1
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/chat.py +148 -58
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/inline_choice.py +10 -3
- {aru_code-0.42.0 → aru_code-0.44.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/SOURCES.txt +1 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/pyproject.toml +1 -1
- aru_code-0.42.0/aru/__init__.py +0 -1
- {aru_code-0.42.0 → aru_code-0.44.0}/LICENSE +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/README.md +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/agent_factory.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/agents/base.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/agents/planner.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/cache_patch.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/checkpoints.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/cli.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/commands.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/completers.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/config.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/context.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/display.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/events.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/format/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/format/manager.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/format/runner.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/history_blocks.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/lsp/client.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/memory/loader.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/memory/store.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/permissions.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/providers.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/runner.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/runtime.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/select.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/session.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/sinks.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/streaming.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tool_policy.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/registry.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/search.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/shell.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/skill.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/web.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/ui.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru/ui.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/setup.cfg +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_catalog.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_codebase.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_config.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_context.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_delegate.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_format.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_lsp.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_main.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_memory.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_permissions.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_plugins.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_providers.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_ranker.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_runtime.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_select.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_permission_flow.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.42.0 → aru_code-0.44.0}/tests/test_worktree.py +0 -0
- {aru_code-0.42.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.
|
|
@@ -1069,23 +1091,114 @@ class AruApp(App):
|
|
|
1069
1091
|
except Exception:
|
|
1070
1092
|
pass
|
|
1071
1093
|
# Layer 9 self-heal — re-assert Textual's mouse-tracking
|
|
1072
|
-
# sequences at
|
|
1073
|
-
#
|
|
1074
|
-
#
|
|
1075
|
-
#
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
|
1089
1202
|
|
|
1090
1203
|
# ── Bus wiring — ToolsPane + StatusPane subscribe to plugin events ──
|
|
1091
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,
|
|
@@ -213,6 +213,138 @@ Treat as a ninth layer: defence-in-depth against terminal-state
|
|
|
213
213
|
corruption. Prong 1 plugs the last known-possible leak inside our
|
|
214
214
|
code; prong 2 recovers even if something outside our reach drops
|
|
215
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.
|
|
216
348
|
"""
|
|
217
349
|
|
|
218
350
|
from __future__ import annotations
|
|
@@ -233,6 +365,11 @@ from textual.containers import VerticalScroll
|
|
|
233
365
|
from textual.reactive import reactive
|
|
234
366
|
from textual.widgets import Static
|
|
235
367
|
|
|
368
|
+
from aru.tui.sanitize import (
|
|
369
|
+
SanitizedRenderable as _SanitizedRenderable,
|
|
370
|
+
sanitize_for_terminal as _sanitize_for_terminal,
|
|
371
|
+
)
|
|
372
|
+
|
|
236
373
|
|
|
237
374
|
# Reference-definition line: ``[label]: href`` (optional leading 0–3 spaces).
|
|
238
375
|
# Presence of *any* reference definition anywhere in the snapshot disables the
|
|
@@ -242,64 +379,6 @@ from textual.widgets import Static
|
|
|
242
379
|
_REF_DEF_RE = re.compile(r"^[ ]{0,3}\[[^\]\n]+\]:\s", re.MULTILINE)
|
|
243
380
|
|
|
244
381
|
|
|
245
|
-
# Strip ASCII control characters (0x00–0x1F plus DEL 0x7F) from any content
|
|
246
|
-
# about to be rendered to the terminal, EXCEPT ``\n`` and ``\t`` which are
|
|
247
|
-
# semantically meaningful to markdown-it, Rich, and Textual. Rich's ``Text``
|
|
248
|
-
# passes escape bytes through verbatim, so if a streamed model reply or a
|
|
249
|
-
# tool label contains ``\x1b[?1000l`` (or any other DEC private-mode escape),
|
|
250
|
-
# the terminal receives it directly and globally disables mouse tracking —
|
|
251
|
-
# at which point every scroll area in the TUI stops responding to the
|
|
252
|
-
# mouse wheel (keyboard still works, which is why the bug presents as
|
|
253
|
-
# "only scroll froze"). Models that talk about terminal control sequences
|
|
254
|
-
# or tools that echo subprocess output are the realistic injection paths.
|
|
255
|
-
_CTRL_CHAR_TRANSLATION = {c: None for c in range(32) if chr(c) not in ("\n", "\t")}
|
|
256
|
-
_CTRL_CHAR_TRANSLATION[0x7F] = None
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def _sanitize_for_terminal(raw: str) -> str:
|
|
260
|
-
"""Remove non-printable control chars so rogue ANSI escapes can't reach the tty.
|
|
261
|
-
|
|
262
|
-
Keeps ``\\n`` and ``\\t``; drops everything else in the C0 range plus DEL.
|
|
263
|
-
Cheap: ``str.translate`` is implemented in C and runs in microseconds for
|
|
264
|
-
multi-KB inputs. Applied at every boundary where chat content becomes a
|
|
265
|
-
Rich renderable.
|
|
266
|
-
"""
|
|
267
|
-
return raw.translate(_CTRL_CHAR_TRANSLATION)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
class _SanitizedRenderable:
|
|
271
|
-
"""Wraps a Rich renderable so its output segments are stripped of C0 bytes.
|
|
272
|
-
|
|
273
|
-
``_sanitize_for_terminal`` covers every string passing through
|
|
274
|
-
``ChatMessageWidget.buffer``. Arbitrary Rich renderables handed to
|
|
275
|
-
``ChatPane.add_renderable`` (plan panels, task lists, diff previews, the
|
|
276
|
-
startup logo) skip that widget and mount as a plain ``Static(renderable)``
|
|
277
|
-
— so a rogue ``\\x1b[?1000l`` inside a task description, a panel title, or
|
|
278
|
-
subprocess output echoed into a panel would flow straight through Rich
|
|
279
|
-
segments to Textual's compositor and onto the terminal, disabling mouse
|
|
280
|
-
tracking globally (Layer 7 signature).
|
|
281
|
-
|
|
282
|
-
This wrapper closes that gap: ``console.render`` yields segments from the
|
|
283
|
-
inner renderable, we strip C0 bytes from any segment whose ``.text``
|
|
284
|
-
contains them, and re-emit the cleaned stream. Rich's ``Segment`` is a
|
|
285
|
-
``NamedTuple`` so ``seg._replace(text=...)`` is a cheap immutable swap.
|
|
286
|
-
Unchanged segments are re-emitted unmodified — the hot path is a single
|
|
287
|
-
``str.translate`` on segment text which typically no-ops.
|
|
288
|
-
"""
|
|
289
|
-
|
|
290
|
-
def __init__(self, inner: Any) -> None:
|
|
291
|
-
self._inner = inner
|
|
292
|
-
|
|
293
|
-
def __rich_console__(self, console: Any, options: Any) -> Any:
|
|
294
|
-
for seg in console.render(self._inner, options):
|
|
295
|
-
if seg.text:
|
|
296
|
-
clean = _sanitize_for_terminal(seg.text)
|
|
297
|
-
if clean != seg.text:
|
|
298
|
-
yield seg._replace(text=clean)
|
|
299
|
-
continue
|
|
300
|
-
yield seg
|
|
301
|
-
|
|
302
|
-
|
|
303
382
|
def _scan_fences(text: str) -> tuple[int, int]:
|
|
304
383
|
"""One-pass fence scanner. Returns ``(last_stable_split, open_fence_start)``.
|
|
305
384
|
|
|
@@ -1080,6 +1159,17 @@ class ChatPane(VerticalScroll):
|
|
|
1080
1159
|
# row bleeding outside the box.
|
|
1081
1160
|
wrapper.styles.padding = 0
|
|
1082
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
|
|
1083
1173
|
self.mount(wrapper)
|
|
1084
1174
|
wrapper.mount(widget)
|
|
1085
1175
|
else:
|
|
@@ -27,6 +27,8 @@ from textual.widget import Widget
|
|
|
27
27
|
from textual.widgets import Label, OptionList
|
|
28
28
|
from textual.widgets.option_list import Option
|
|
29
29
|
|
|
30
|
+
from aru.tui.sanitize import sanitize_for_terminal
|
|
31
|
+
|
|
30
32
|
|
|
31
33
|
class InlineChoicePrompt(Widget):
|
|
32
34
|
"""Approval prompt rendered as a bordered widget in the ChatPane flow."""
|
|
@@ -82,13 +84,18 @@ class InlineChoicePrompt(Widget):
|
|
|
82
84
|
self._fired = False
|
|
83
85
|
|
|
84
86
|
def compose(self) -> ComposeResult:
|
|
87
|
+
# Sanitise every caller-supplied string — title, hint, and option
|
|
88
|
+
# labels can carry text from the agent, a tool result, or a plan
|
|
89
|
+
# summary, all of which may contain raw C0 escapes that would
|
|
90
|
+
# disable mouse tracking globally if they reached the terminal.
|
|
91
|
+
# Same boundary as ``ChoiceModal``; see Layer 10 in chat.py.
|
|
85
92
|
if self._title:
|
|
86
|
-
yield Label(self._title, classes="title")
|
|
93
|
+
yield Label(sanitize_for_terminal(self._title), classes="title")
|
|
87
94
|
if self._hint:
|
|
88
|
-
yield Label(self._hint, classes="hint")
|
|
95
|
+
yield Label(sanitize_for_terminal(self._hint), classes="hint")
|
|
89
96
|
yield OptionList(
|
|
90
97
|
*[
|
|
91
|
-
Option(label, id=str(i))
|
|
98
|
+
Option(sanitize_for_terminal(label), id=str(i))
|
|
92
99
|
for i, label in enumerate(self._options)
|
|
93
100
|
],
|
|
94
101
|
)
|
aru_code-0.42.0/aru/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.42.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
|