diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6f205a1039f..f1f83333124 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -65,6 +65,39 @@ import os import sys +# Mouse-tracking residue suppression — runs BEFORE every other import on the +# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the +# Python launcher is still doing imports (≈100–300ms in cooked + echo mode, +# before the Node TUI takes stdin into raw mode). During that window any +# incoming bytes are echoed straight back to the user's shell scrollback as +# ``^[[<…M`` text. The TUI itself runs `resetTerminalModes()` again in +# `entry.tsx`; this is just the earlier cousin. ``HERMES_TUI_NO_EARLY_DISABLE`` +# escapes the behaviour for diagnostics. +def _suppress_mouse_residue_early() -> None: + if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1": + return + if not (os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]): + return + try: + # Skip when stdout is redirected (`hermes --tui … >log`, CI capture): + # the bytes can't reach the terminal anyway and would just pollute + # the log with raw CSI. + if not os.isatty(1): + return + # Disable every mouse-tracking variant we know about. Idempotent and + # safe to send even when no tracking is currently asserted. + os.write( + 1, + b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l" + b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l", + ) + except OSError: + pass + + +_suppress_mouse_residue_early() + + def _is_termux_startup_environment_fast() -> bool: """Tiny Termux check for pre-import startup shortcuts.""" prefix = os.environ.get("PREFIX", "") diff --git a/tests/hermes_cli/test_tui_mouse_residue_suppression.py b/tests/hermes_cli/test_tui_mouse_residue_suppression.py new file mode 100644 index 00000000000..c8b646f38d1 --- /dev/null +++ b/tests/hermes_cli/test_tui_mouse_residue_suppression.py @@ -0,0 +1,92 @@ +"""Tests for the TUI-hot-path mouse-residue suppression. + +The Python launcher (`hermes --tui …`) has a ~100–300ms cold-start window +where stdin is still in cooked + echo mode. If a previous Hermes session +left DEC mouse-tracking asserted, any mouse motion during that window +echoes literal ``^[[<…M`` text into the user's scrollback. + +`_suppress_mouse_residue_early()` writes the disable sequence to stdout +before the heavy imports so the terminal stops emitting events ASAP. +""" + +from __future__ import annotations + +import sys +from unittest.mock import patch + +# Importing the module triggers `_suppress_mouse_residue_early()` at module +# scope. Under the test runner argv (`pytest …`) it's a no-op, but we import +# at file scope so individual tests don't race the import side-effect with +# their `patch("os.write")` context. +from hermes_cli.main import _suppress_mouse_residue_early + +EXPECTED = ( + b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l" + b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l" +) + + +class TestEarlyMouseDisable: + def test_writes_disable_sequence_when_tui_flag_in_argv(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "-c", "abc"]) + monkeypatch.delenv("HERMES_TUI", raising=False) + monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False) + + with patch("os.isatty", return_value=True), patch("os.write") as mock_write: + _suppress_mouse_residue_early() + + mock_write.assert_called_once_with(1, EXPECTED) + + def test_writes_disable_sequence_when_hermes_tui_env_set(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["hermes"]) + monkeypatch.setenv("HERMES_TUI", "1") + monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False) + + with patch("os.isatty", return_value=True), patch("os.write") as mock_write: + _suppress_mouse_residue_early() + + mock_write.assert_called_once_with(1, EXPECTED) + + def test_no_op_on_non_tui_invocation(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["hermes", "--version"]) + monkeypatch.delenv("HERMES_TUI", raising=False) + monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False) + + with patch("os.write") as mock_write: + _suppress_mouse_residue_early() + + mock_write.assert_not_called() + + def test_respects_diagnostic_escape_hatch(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["hermes", "--tui"]) + monkeypatch.delenv("HERMES_TUI", raising=False) + monkeypatch.setenv("HERMES_TUI_NO_EARLY_DISABLE", "1") + + with patch("os.write") as mock_write: + _suppress_mouse_residue_early() + + mock_write.assert_not_called() + + def test_skips_when_stdout_is_not_a_tty(self, monkeypatch): + # `hermes --tui … >log` or CI capture: pipe is fd 1, not a TTY. The + # bytes can't reach a terminal and would just pollute the log. + monkeypatch.setattr(sys, "argv", ["hermes", "--tui"]) + monkeypatch.delenv("HERMES_TUI", raising=False) + monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False) + + with patch("os.isatty", return_value=False), patch("os.write") as mock_write: + _suppress_mouse_residue_early() + + mock_write.assert_not_called() + + def test_oserror_is_swallowed(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["hermes", "--tui"]) + monkeypatch.delenv("HERMES_TUI", raising=False) + monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False) + + def boom(*_a, **_k): + raise OSError("stdout closed") + + with patch("os.isatty", return_value=True), patch("os.write", side_effect=boom): + # Must not propagate — startup hot path can never break. + _suppress_mouse_residue_early()