{"repo":"NousResearch/hermes-agent","public_id":"36823","display_label":"TUI exit leaves terminal in bad input mode","summary":"A bug where the TUI fails to restore terminal input modes on exit, causing focus-reporting and mouse escape sequences to leak into the shell. Multiple fixes and a process exit backstop address this cleanup.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":36864,"merged_at":"2026-06-02T15:30:50+00:00"},"resolution":{"state":"resolved","by":"pr-36864"},"freshness":{"state":"verified","last_verified_at":"2026-06-02T15:30:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":36823,"pr_number":36864,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-02T15:30:50+00:00"}],"members":[{"kind":"issue","number":36823,"title":"[Bug]: TUI leaves focus-reporting mode enabled on exit — raw escape sequences leak into terminal","url":null,"state":"closed"},{"kind":"pr","number":36864,"title":"fix(cli): reset terminal input modes on TUI exit to stop focus/mouse leaks","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_tui_terminal_reset_on_exit.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -872,6 +872,17 @@ def _cleanup_all_browsers(*args, **kwargs):\n # Weak reference to the active AIAgent for memory provider shutdown at exit\n _active_agent_ref = None\n _deferred_agent_startup_done = False\n+# Set True once the TUI's prompt_toolkit app starts (which enables focus\n+# reporting + mouse tracking). Gates the on-exit terminal reset so non-TUI\n+# one-shot CLI runs — which also register _run_cleanup via atexit — don't emit\n+# escape codes for modes they never enabled (#36823).\n+_tui_input_modes_active = False\n+\n+\n+def _mark_tui_input_modes_active() -> None:\n+    \"\"\"Record that the TUI app started, so _run_cleanup resets input modes.\"\"\"\n+    global _tui_input_modes_active\n+    _tui_input_modes_active = True\n \n \n def _prepare_deferred_agent_startup() -> None:","@@ -927,6 +938,12 @@ def _run_cleanup():\n         return\n     _cleanup_done = True\n \n+    # Reset terminal input modes first, before the slower resource teardown\n+    # below (MCP / browser / memory shutdown can take seconds). On Ctrl+C the\n+    # user's terminal becomes usable immediately, and a later step raising\n+    # can't skip the reset (#36823). No-op unless the TUI actually ran.\n+    _reset_terminal_input_modes_on_exit()\n+\n     try:\n         _cleanup_all_terminals()\n     except Exception:","@@ -972,6 +989,50 @@ def _run_cleanup():\n         pass\n \n \n+def _reset_terminal_input_modes_on_exit() -> None:\n+    \"\"\"Best-effort: disable focus reporting + mouse tracking on TUI exit so they\n+    don't leak into the next shell session sharing the tab.\n+\n+    prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM /\n+    SIGHUP and crashes can bypass its unwind, leaving the modes enabled. The\n+    terminal then emits raw ``ESC[I`` / ``ESC[O`` focus events and fragmented\n+    SGR mouse reports as visible text in whatever runs next in the same tab\n+    (#36823). Called from ``_run_cleanup`` (atexit-registered + invoked on the\n+    normal / EOF / interrupt exit paths) this covers normal quit, Ctrl+C and\n+    SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's\n+    ``os._exit(0)`` path bypasses ``atexit``; neither runs this — but both are\n+    non-TTY / non-TUI, so there is nothing to reset there.\n+\n+    Gated on ``_tui_input_modes_active`` so one-shot non-TUI CLI runs (which\n+    share ``_run_cleanup`` via ``atexit``) never emit these codes. Writes to the\n+    controlling terminal directly: by exit, prompt_toolkit's own output is torn\n+    down, so ``sys.stdout`` is the real fd; falls back to ``/dev/tty`` when\n+    stdout is redirected away from the terminal.\n+    \"\"\"\n+    global _tui_input_modes_active\n+    if not _tui_input_modes_active:\n+        return\n+    # About to disable the modes — clear the flag so a re-armed _run_cleanup (or\n+    # a long-lived process that reuses it) doesn't re-emit them.\n+    _tui_input_modes_active = False\n+    # Prefer stdout when it's the terminal; otherwise the TUI may have driven\n+    # /dev/tty while stdout was redirected — reset there instead of nowhere.\n+    try:\n+        stream = sys.stdout\n+        if stream is not None and stream.isatty():\n+            stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)\n+            stream.flush()\n+            return\n+    except Exception:\n+        pass\n+    try:\n+        with open(\"/dev/tty\", \"w\", encoding=\"ascii\") as tty:\n+            tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)\n+            tty.flush()\n+    except Exception:\n+        pass\n+\n+\n # =============================================================================\n # Git Worktree Isolation (#652)\n # =============================================================================","@@ -15096,6 +15157,9 @@ def new_event_loop(self):\n                     pass  # No running loop -- nothing to patch\n                 except Exception:\n                     pass\n+                # The app enables focus reporting + mouse tracking; record that\n+                # so _run_cleanup resets them on exit (#36823).\n+                _mark_tui_input_modes_active()\n                 app.run()\n         except (EOFError, KeyboardInterrupt, BrokenPipeError):\n             pass"]},{"file":"tests/cli/test_tui_terminal_reset_on_exit.py","hunks":["@@ -0,0 +1,212 @@\n+\"\"\"Regression tests for GitHub #36823 — the TUI must reset terminal input\n+modes on exit so focus-reporting / mouse-tracking escape sequences don't leak\n+into the next shell session sharing the tab.\n+\n+prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM/SIGHUP\n+and crashes can bypass its unwind. ``_run_cleanup`` (the once-only cleanup that\n+runs on every catchable exit path, including ``atexit``) now emits the disable\n+sequence as its first step via ``_reset_terminal_input_modes_on_exit`` — gated\n+on ``_tui_input_modes_active`` so non-TUI one-shot CLI runs (which share\n+``_run_cleanup`` via ``atexit``) don't emit codes for modes they never set.\n+\"\"\"\n+\n+import unittest\n+from unittest.mock import mock_open, patch\n+\n+\n+def _import_cli():\n+    import hermes_cli.config as config_mod\n+\n+    if not hasattr(config_mod, \"save_env_value_secure\"):\n+        config_mod.save_env_value_secure = lambda key, value: {\n+            \"success\": True,\n+            \"stored_as\": key,\n+            \"validated\": False,\n+        }\n+\n+    import cli as cli_mod\n+\n+    return cli_mod\n+\n+\n+class _FakeStream:\n+    def __init__(self, isatty: bool = True):\n+        self._isatty = isatty\n+        self.written: list[str] = []\n+        self.flushed = 0\n+\n+    def isatty(self) -> bool:\n+        return self._isatty\n+\n+    def write(self, s: str) -> int:\n+        self.written.append(s)\n+        return len(s)\n+\n+    def flush(self) -> None:\n+        self.flushed += 1\n+\n+\n+class TestResetTerminalInputModes(unittest.TestCase):\n+    def test_emits_reset_seq_on_tty_when_tui_ran(self):\n+        cli_mod = _import_cli()\n+        fake = _FakeStream(isatty=True)\n+        with (\n+            patch.object(cli_mod, \"_tui_input_modes_active\", True),\n+            patch.object(cli_mod.sys, \"stdout\", fake),\n+        ):\n+            cli_mod._reset_terminal_input_modes_on_exit()\n+\n+        written = \"\".join(fake.written)\n+        self.assertEqual(written, cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)\n+        self.assertGreaterEqual(fake.flushed, 1)\n+        # The focus-reporting disable is the specific leak the issue reports.\n+        self.assertIn(\"\\x1b[?1004l\", written)\n+\n+    def test_noop_when_tui_never_ran(self):\n+        \"\"\"Non-TUI one-shot CLI runs share _run_cleanup via atexit — they must\n+        not emit terminal escape codes they never needed (review finding #1).\"\"\"\n+        cli_mod = _import_cli()\n+        fake = _FakeStream(isatty=True)\n+        with (\n+            patch.object(cli_mod, \"_tui_input_modes_active\", False),\n+            patch.object(cli_mod.sys, \"stdout\", fake),\n+            # Guard: must not touch the real /dev/tty either.\n+            patch(\"builtins.open\", mock_open()) as m_open,\n+        ):\n+            cli_mod._reset_terminal_input_modes_on_exit()\n+\n+        self.assertEqual(fake.written, [])\n+        m_open.assert_not_called()\n+\n+    def test_noop_when_not_a_tty_and_no_dev_tty(self):\n+        \"\"\"stdout redirected and /dev/tty unavailable → nothing written, no raise.\"\"\"\n+        cli_mod = _import_cli()\n+        fake = _FakeStream(isatty=False)\n+        with (\n+            patch.object(cli_mod, \"_tui_input_modes_active\", True),\n+            patch.object(cli_mod.sys, \"stdout\", fake),\n+            patch(\"builtins.open\", side_effect=OSError(\"no /dev/tty\")),\n+        ):\n+            cli_mod._reset_terminal_input_modes_on_exit()\n+\n+        self.assertEqual(fake.written, [], \"must not pollute the redirected stream\")\n+\n+    def test_falls_back_to_dev_tty_when_stdout_redirected(self):\n+        \"\"\"When stdout isn't the terminal, reset via /dev/tty (issue's own\n+        suggestion) so a TUI that drove /dev/tty still gets cleaned up.\"\"\"\n+        cli_mod = _import_cli()\n+        fake = _FakeStream(isatty=False)\n+        m_open = mock_open()\n+        with (\n+            patch.object(cli_mod, \"_tui_input_modes_active\", True),\n+            patch.object(cli_mod.sys, \"stdout\", fake),\n+            patch(\"builtins.open\", m_open),\n+        ):\n+            cli_mod._reset_terminal_input_modes_on_exit()\n+\n+        self.assertEqual(fake.written, [])\n+        m_open.assert_called_once_with(\"/dev/tty\", \"w\", encoding=\"ascii\")\n+        m_open().write.assert_called_once_with(cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)\n+\n+    def test_swallows_stdout_errors(self):\n+        cli_mod = _import_cli()\n+\n+        class _Boom:\n+            def isatty(self):\n+                raise OSError(\"stdout closed\")\n+\n+        with (\n+            patch.object(cli_mod, \"_tui_input_modes_active\", True),\n+            patch.object(cli_mod.sys, \"stdout\", _Boom()),\n+            patch(\"builtins.open\", side_effect=OSError(\"no /dev/tty\")),\n+        ):\n+            # Cleanup runs at process teardown — it must never raise.\n+            cli_mod._reset_terminal_input_modes_on_exit()\n+\n+    def test_mark_tui_input_modes_active_sets_flag(self):\n+        cli_mod = _import_cli()\n+        original = cli_mod._tui_input_modes_active\n+        cli_mod._tui_input_modes_active = False\n+        try:\n+            cli_mod._mark_tui_input_modes_active()\n+            self.assertTrue(cli_mod._tui_input_modes_active)\n+        finally:\n+            cli_mod._tui_input_modes_active = original\n+\n+    def test_flag_cleared_after_reset(self):\n+        \"\"\"Once the modes are disabled they are no longer active — the flag must\n+        flip back so a re-armed cleanup doesn't re-emit the sequence.\"\"\"\n+        cli_mod = _import_cli()\n+        fake = _FakeStream(isatty=True)\n+        original = cli_mod._tui_input_modes_active\n+        cli_mod._tui_input_modes_active = True\n+        try:\n+            with patch.object(cli_mod.sys, \"stdout\", fake):\n+                cli_mod._reset_terminal_input_modes_on_exit()\n+            self.assertIn(\"\\x1b[?1004l\", \"\".join(fake.written))\n+            self.assertFalse(\n+                cli_mod._tui_input_modes_active, \"flag must clear after reset\"\n+            )\n+        finally:\n+            cli_mod._tui_input_modes_active = original\n+\n+\n+class TestRunCleanupWiring(unittest.TestCase):\n+    \"\"\"_run_cleanup must call the reset, as its first step, on every invocation\n+    — even if a later cleanup step raises.\"\"\"\n+\n+    def _run_cleanup_isolated(self, cli_mod, **extra_patches):\n+        \"\"\"Invoke _run_cleanup with heavy/real teardown steps stubbed out so the\n+        test is hermetic (review finding #5).\"\"\"\n+        original_done = cli_mod._cleanup_done\n+        cli_mod._cleanup_done = False\n+        patches = {\n+            \"_cleanup_all_terminals\": lambda: None,\n+            \"_cleanup_all_browsers\": lambda: None,\n+        }\n+        try:\n+            with (\n+                patch.object(\n+                    cli_mod, \"_reset_terminal_input_modes_on_exit\"\n+                ) as mock_reset,\n+                patch.object(\n+                    cli_mod, \"_cleanup_all_terminals\", patches[\"_cleanup_all_terminals\"]\n+                ),\n+                patch.object(\n+                    cli_mod, \"_cleanup_all_browsers\", patches[\"_cleanup_all_browsers\"]\n+                ),\n+                patch(\"tools.mcp_tool.shutdown_mcp_servers\", lambda *a, **k: None),\n+                patch(\n+                    \"agent.auxiliary_client.shutdown_cached_clients\",\n+                    lambda *a, **k: None,\n+                ),\n+                patch(\"hermes_cli.plugins.invoke_hook\", lambda *a, **k: None),\n+            ):\n+                if extra_patches.get(\"terminals_raise\"):\n+                    with patch.object(\n+                        cli_mod,\n+                        \"_cleanup_all_terminals\",\n+                        side_effect=RuntimeError(\"boom\"),\n+                    ):\n+                        cli_mod._run_cleanup()\n+                else:\n+                    cli_mod._run_cleanup()\n+                return mock_reset\n+        finally:\n+            cli_mod._cleanup_done = original_done\n+\n+    def test_run_cleanup_calls_reset(self):\n+        cli_mod = _import_cli()\n+        mock_reset = self._run_cleanup_isolated(cli_mod)\n+        mock_reset.assert_called_once()\n+\n+    def test_reset_runs_even_when_a_cleanup_step_raises(self):\n+        \"\"\"The reset is the first step, so a failing teardown step can't skip\n+        it — covering the Ctrl+C / crash paths the issue is about.\"\"\"\n+        cli_mod = _import_cli()\n+        mock_reset = self._run_cleanup_isolated(cli_mod, terminals_raise=True)\n+        mock_reset.assert_called_once()\n+\n+\n+if __name__ == \"__main__\":\n+    unittest.main()\n"]}]},{"kind":"pr","number":37458,"title":"fix(cli): reset terminal input modes on TUI exit to stop focus/mouse leaks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36864","number":36864}],"category":"merged_via","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}