{"repo":"NousResearch/hermes-agent","public_id":"41098","display_label":"CLI approval modal blocked by invalidate throttle","summary":"A bug where the CLI approval/clarify/sudo modal prompts fail to render because the _invalidate() throttle drops their redraw calls. Multiple fix attempts address forcing redraws past the throttle and resize guard.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":41155,"merged_at":"2026-06-08T08:01:51+00:00"},"resolution":{"state":"resolved","by":"pr-41155"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T08:01:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":41098,"pr_number":41155,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-08T08:01:51+00:00"}],"members":[{"kind":"issue","number":41098,"title":"Approval panel never renders — _invalidate() throttle drops redraw","url":null,"state":"closed"},{"kind":"pr","number":41116,"title":"fix(cli): force approval panel redraw past the _invalidate throttle (#41098)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41155","number":41155}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":41141,"title":"fix(cli): render approval/clarify/sudo modal prompts past _invalidate throttle + resize guard (#41098)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41155","number":41155}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":41155,"title":"fix(cli): paint approval/clarify/sudo/secret modal prompts directly, not via the throttle (#41098)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_cli_approval_ui.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -3479,7 +3479,22 @@ def __init__(\n         self._background_task_counter = 0\n \n     def _invalidate(self, min_interval: float = 0.25) -> None:\n-        \"\"\"Throttled UI repaint — prevents terminal blinking on slow/SSH connections.\"\"\"\n+        \"\"\"Throttled UI repaint for high-frequency background updates.\n+\n+        Use this for spinner frames, streaming token flushes, and other\n+        repaints that can fire many times per second — the throttle prevents\n+        terminal blinking on slow/SSH connections, and the resize-recovery\n+        guard avoids stamping footer/status-bar chrome into scrollback while a\n+        SIGWINCH reflow is in flight.\n+\n+        Do NOT use this for user-blocking modal prompts (approval / clarify /\n+        sudo). Those are rare, one-shot, user-blocking events that must paint\n+        immediately; route them through ``self._app.invalidate()`` directly, the\n+        same way the modal key-binding handlers already do. Sending a modal's\n+        entry paint through this throttle lets an unrelated background repaint\n+        within the 250ms window — or an in-flight resize — silently drop it, so\n+        the prompt never renders and times out unseen (#41098).\n+        \"\"\"\n         if getattr(self, \"_resize_recovery_pending\", False):\n             return\n         now = time.monotonic()","@@ -3487,6 +3502,24 @@ def _invalidate(self, min_interval: float = 0.25) -> None:\n             self._last_invalidate = now\n             self._app.invalidate()\n \n+    def _paint_now(self) -> None:\n+        \"\"\"Immediate, unthrottled repaint for user-blocking modal prompts.\n+\n+        Background-thread callbacks (approval / clarify / sudo) set their modal\n+        state then call this to make the panel visible at once. It deliberately\n+        bypasses the ``_invalidate`` throttle and resize-recovery guard — a\n+        modal the user is actively waiting on must never be dropped — mirroring\n+        the direct ``event.app.invalidate()`` the modal key-binding handlers\n+        already use. See ``_invalidate`` for why the throttle must not gate\n+        these paints (#41098).\n+        \"\"\"\n+        app = getattr(self, \"_app\", None)\n+        if app is not None:\n+            try:\n+                app.invalidate()\n+            except Exception:\n+                pass\n+\n     def _force_full_redraw(self) -> None:\n         \"\"\"Force a clean full-screen repaint of the prompt_toolkit UI.\n ","@@ -11801,18 +11834,15 @@ def _clarify_callback(self, question, choices):\n         # Open-ended questions skip straight to freetext input\n         self._clarify_freetext = is_open_ended\n \n-        # Trigger prompt_toolkit repaint from this (non-main) thread\n-        self._invalidate()\n+        # Trigger an immediate prompt_toolkit repaint from this (non-main)\n+        # thread. Modal prompts must paint at once and must not be gated by the\n+        # _invalidate throttle / resize guard — see _paint_now / _invalidate (#41098).\n+        self._paint_now()\n \n-        # Poll for the user's response.  The countdown in the hint line\n-        # updates on each invalidate — but frequent repaints cause visible\n-        # flicker in some terminals (Kitty, ghostty).  We only refresh the\n-        # countdown every 5 s; selection changes (↑/↓) trigger instant\n-        # Poll for the user's response.  The countdown in the hint line\n-        # updates on each invalidate — but frequent repaints cause visible\n-        # flicker in some terminals (Kitty, ghostty).  We only refresh the\n-        # countdown every 5 s; selection changes (↑/↓) trigger instant\n-        # repaints via the key bindings.\n+        # Poll for the user's response. The countdown in the hint line updates\n+        # on each repaint; refresh it once a second so the timer stays visible\n+        # while we wait. Selection changes (↑/↓) trigger instant repaints via\n+        # the key bindings.\n         _last_countdown_refresh = _time.monotonic()\n         while True:\n             try:","@@ -11823,20 +11853,16 @@ def _clarify_callback(self, question, choices):\n                 remaining = self._clarify_deadline - _time.monotonic()\n                 if remaining <= 0:\n                     break\n-                # Only repaint every 5 s for the countdown — avoids flicker\n                 now = _time.monotonic()\n-                if now - _last_countdown_refresh >= 5.0:\n-                    _last_countdown_refresh = now\n-                    self._invalidate()\n-                if now - _last_countdown_refresh >= 5.0:\n+                if now - _last_countdown_refresh >= 1.0:\n                     _last_countdown_refresh = now\n-                    self._invalidate()\n+                    self._paint_now()\n \n         # Timed out — tear down the UI and let the agent decide\n         self._clarify_state = None\n         self._clarify_freetext = False\n         self._clarify_deadline = 0\n-        self._invalidate()\n+        self._paint_now()\n         _cprint(f\"\\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}\")\n         return (\n             \"The user did not provide a response within the time limit. \"","@@ -11862,7 +11888,9 @@ def _sudo_password_callback(self) -> str:\n         }\n         self._sudo_deadline = _time.monotonic() + timeout\n \n-        self._invalidate()\n+        # Modal prompt — paint immediately, bypassing the throttle/resize guard\n+        # so the prompt can't be dropped and time out unseen (#41098).\n+        self._paint_now()\n \n         while True:\n             try:","@@ -11870,7 +11898,7 @@ def _sudo_password_callback(self) -> str:\n                 self._sudo_state = None\n                 self._sudo_deadline = 0\n                 self._restore_modal_input_snapshot()\n-                self._invalidate()\n+                self._paint_now()\n                 if result:\n                     _cprint(f\"\\n{_DIM}  ✓ Password received (cached for session){_RST}\")\n                 else:","@@ -11880,12 +11908,12 @@ def _sudo_password_callback(self) -> str:\n                 remaining = self._sudo_deadline - _time.monotonic()\n                 if remaining <= 0:\n                     break\n-                self._invalidate()\n+                self._paint_now()\n \n         self._sudo_state = None\n         self._sudo_deadline = 0\n         self._restore_modal_input_snapshot()\n-        self._invalidate()\n+        self._paint_now()\n         _cprint(f\"\\n{_DIM}  ⏱ Timeout — continuing without sudo{_RST}\")\n         return \"\"\n ","@@ -11919,7 +11947,12 @@ def _approval_callback(self, command: str, description: str,\n             }\n             self._approval_deadline = _time.monotonic() + timeout\n \n-            self._invalidate()\n+            # Modal prompt — paint immediately, bypassing the throttle/resize\n+            # guard. A throttled paint here can be silently dropped (250ms\n+            # window collision or in-flight resize), leaving the panel unseen so\n+            # the command is denied on timeout without the user ever seeing it\n+            # (#41098). The countdown refreshes below paint the same way.\n+            self._paint_now()\n \n             _last_countdown_refresh = _time.monotonic()\n             while True:","@@ -11927,20 +11960,20 @@ def _approval_callback(self, command: str, description: str,\n                     result = response_queue.get(timeout=1)\n                     self._approval_state = None\n                     self._approval_deadline = 0\n-                    self._invalidate()\n+                    self._paint_now()\n                     return result\n                 except queue.Empty:\n                     remaining = self._approval_deadline - _time.monotonic()\n                     if remaining <= 0:\n                         break\n                     now = _time.monotonic()\n-                    if now - _last_countdown_refresh >= 5.0:\n+                    if now - _last_countdown_refresh >= 1.0:\n                         _last_countdown_refresh = now\n-                        self._invalidate()\n+                        self._paint_now()\n \n             self._approval_state = None\n             self._approval_deadline = 0\n-            self._invalidate()\n+            self._paint_now()\n             _cprint(f\"\\n{_DIM}  ⏱ Timeout — denying command{_RST}\")\n             return \"deny\"\n ","@@ -12198,7 +12231,9 @@ def _submit_secret_response(self, value: str) -> None:\n         self._secret_state[\"response_queue\"].put(value)\n         self._secret_state = None\n         self._secret_deadline = 0\n-        self._invalidate()\n+        # Modal teardown — paint directly so the secret panel clears at once and\n+        # isn't held by the _invalidate throttle/resize guard (#41098).\n+        self._paint_now()\n \n     def _cancel_secret_capture(self) -> None:\n         self._submit_secret_response(\"\")"]},{"file":"tests/cli/test_cli_approval_ui.py","hunks":["@@ -339,6 +339,123 @@ def run_conversation(self, **kwargs):\n         assert not cli._background_tasks\n \n \n+def _make_real_paint_cli_stub():\n+    \"\"\"A stub whose modal repaint path runs the REAL _paint_now / _invalidate.\n+\n+    Both gates are set adversarially: _resize_recovery_pending=True and a recent\n+    _last_invalidate inside the throttle window. A throttled _invalidate() would\n+    be dropped under these conditions — _paint_now must paint regardless.\n+    \"\"\"\n+    cli = HermesCLI.__new__(HermesCLI)\n+    cli._approval_state = None\n+    cli._approval_deadline = 0\n+    cli._approval_lock = threading.Lock()\n+    cli._sudo_state = None\n+    cli._sudo_deadline = 0\n+    cli._clarify_state = None\n+    cli._clarify_freetext = False\n+    cli._clarify_deadline = 0\n+    cli._modal_input_snapshot = None\n+    # Real methods, not mocks.\n+    cli._paint_now = HermesCLI._paint_now.__get__(cli, HermesCLI)\n+    cli._invalidate = HermesCLI._invalidate.__get__(cli, HermesCLI)\n+    cli._resize_recovery_pending = True       # gate 1: resize in flight\n+    cli._last_invalidate = time.monotonic()   # gate 2: inside throttle window\n+    cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())\n+    return cli\n+\n+\n+class TestModalPaintNow:\n+    \"\"\"Regression for #41098 — modal prompts must paint immediately.\n+\n+    The dangerous-command approval, clarify, and sudo prompts run their wait\n+    loop on a background thread, set modal state a ConditionalContainer reads,\n+    then must repaint so the panel becomes visible. They used the throttled\n+    _invalidate(), whose paint is silently dropped on a 250ms window collision\n+    or while a resize is pending — so the prompt timed out unseen. They now use\n+    _paint_now(), which paints directly like the modal key-binding handlers.\n+    \"\"\"\n+\n+    def test_paint_now_bypasses_throttle_and_resize_guard(self):\n+        cli = _make_real_paint_cli_stub()\n+        # A bare _invalidate() is suppressed under both gates...\n+        cli._invalidate()\n+        assert not cli._app.invalidate.called\n+        # ...but _paint_now() always paints.\n+        cli._paint_now()\n+        assert cli._app.invalidate.called\n+\n+    def test_paint_now_no_app_is_safe(self):\n+        cli = HermesCLI.__new__(HermesCLI)\n+        cli._app = None\n+        cli._paint_now()  # must not raise\n+\n+    def _drive(self, cli, target, state_attr):\n+        result = {}\n+\n+        def _run():\n+            result[\"value\"] = target()\n+\n+        with patch.object(cli_module, \"_cprint\"):\n+            thread = threading.Thread(target=_run, daemon=True)\n+            thread.start()\n+            deadline = time.time() + 2\n+            while getattr(cli, state_attr) is None and time.time() < deadline:\n+                time.sleep(0.01)\n+            assert getattr(cli, state_attr) is not None\n+            assert cli._app.invalidate.called, (\n+                f\"{state_attr} panel was not painted despite throttle + resize gates\"\n+            )\n+            # Reset so we can prove the response-received teardown also repaints\n+            # (the panel must clear at once, not be held by the throttle).\n+            cli._app.invalidate.reset_mock()\n+            getattr(cli, state_attr)[\"response_queue\"].put(\n+                \"deny\" if state_attr == \"_approval_state\" else\n+                (\"a\" if state_attr == \"_clarify_state\" else \"pw\")\n+            )\n+            thread.join(timeout=2)\n+            # clarify returns immediately on a response (no teardown repaint);\n+            # approval and sudo repaint to tear the panel down.\n+            if state_attr != \"_clarify_state\":\n+                assert cli._app.invalidate.called, (\n+                    f\"{state_attr} panel was not repainted on teardown\"\n+                )\n+        assert not thread.is_alive()\n+        return result[\"value\"]\n+\n+    def test_approval_prompt_paints_under_both_gates(self):\n+        cli = _make_real_paint_cli_stub()\n+        value = self._drive(\n+            cli, lambda: cli._approval_callback(\"rm -rf /tmp/scratch\", \"danger\"),\n+            \"_approval_state\",\n+        )\n+        assert value == \"deny\"\n+\n+    def test_clarify_prompt_paints_under_both_gates(self):\n+        cli = _make_real_paint_cli_stub()\n+        value = self._drive(\n+            cli, lambda: cli._clarify_callback(\"Pick one\", [\"a\", \"b\"]),\n+            \"_clarify_state\",\n+        )\n+        assert value == \"a\"\n+\n+    def test_sudo_prompt_paints_under_both_gates(self):\n+        cli = _make_real_paint_cli_stub()\n+        value = self._drive(cli, cli._sudo_password_callback, \"_sudo_state\")\n+        assert value == \"pw\"\n+\n+    def test_secret_response_teardown_paints(self):\n+        \"\"\"_submit_secret_response tears the secret panel down via _paint_now,\n+        so the panel clears immediately rather than being held by the throttle.\"\"\"\n+        cli = _make_real_paint_cli_stub()\n+        cli._secret_state = {\"response_queue\": queue.Queue()}\n+        cli._secret_deadline = 0\n+        cli._submit_secret_response(\"hunter2\")\n+        assert cli._secret_state is None\n+        assert cli._app.invalidate.called\n+        assert cli._secret_state is None  # cleared\n+\n+\n class TestApprovalCallbackThreadLocalWiring:\n     \"\"\"Regression guard for the thread-local callback freeze (#13617 / #13618).\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}