fix(cli): paint approval/clarify/sudo/secret modal prompts directly, not via the throttle (#41098)
In classic CLI mode the dangerous-command approval prompt (and the clarify, sudo, and secret-capture prompts) could fail to render: the user saw '⏱ Timeout — denying command' after 60s without ever seeing the panel, making approvals.mode: manual unusable. Root cause. These prompts run their wait loop on the agent/background thread: they set modal state that a ConditionalContainer's filter reads, then call self._invalidate() to repaint so the panel appears. _invalidate() is a THROTTLED wrapper built for high-frequency background repaints (spinner frames, streaming) — it (a) returns early while a SIGWINCH resize-recovery is pending, and (b) otherwise only repaints if 250ms elapsed since the last paint. Under either condition the modal's entry paint is silently dropped, the ConditionalContainer never re-evaluates, and the prompt times out unseen. The throttle never belonged on these paths. Originally the callbacks painted with a direct self._app.invalidate() and worked; a throttle PR blanket-replaced every invalidate (including these rare, one-shot, user-blocking modal paints) with the throttled _invalidate(); a later commit removed an idle 1Hz repaint that had been masking dropped modal paints, surfacing the bug. Notably the modal KEY-BINDING handlers (↑/↓/Enter) already paint with a direct event.app.invalidate(), never the throttle — the background-thread callbacks were the inconsistent ones. Fix. Add a small _paint_now() helper that paints directly (guarded for a missing _app, exception-safe) and route the four modal paths' entry, response, countdown, and teardown paints through it — matching the key-handler idiom. This covers approval, clarify, sudo, and the secret-capture teardown (_submit_secret_response, which previously used the throttled _invalidate() so its panel could linger after submit). _invalidate() is left untouched and its docstring now states it is for high-frequency background repaints only; modal/interactive paints must use _paint_now()/_app.invalidate() directly. This also fixes the resize-recovery edge case for free (a direct paint never consults the resize guard) without a throttle-bypass flag that could be cargo-culted onto hot paths. Countdown refresh cadence tightened 5s->1s so the timer stays visible while waiting, and a copy-pasted duplicate countdown block in _clarify_callback is removed. Tests: TestModalPaintNow drives all three wait-loop callbacks on a background thread with BOTH gates active (_resize_recovery_pending=True + a recent _last_invalidate in the throttle window) and asserts the panel paints on entry AND repaints on teardown; plus a secret-teardown test, a direct _paint_now-vs-_invalidate gate test, and a no-_app safety test. Each modal test fails if its paint is reverted to _invalidate(). 17 in-file tests pass; full tests/cli suite green (900). Diagnosis credit: the throttle-drop root cause was identified by @sanidhyasin in #41116; @islam666 independently reached the same direct-invalidate approach in #41166; original report #41098 by @jodonnel.
This commit is contained in:
@@ -3479,7 +3479,22 @@ class HermesCLI:
|
||||
self._background_task_counter = 0
|
||||
|
||||
def _invalidate(self, min_interval: float = 0.25) -> None:
|
||||
"""Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
|
||||
"""Throttled UI repaint for high-frequency background updates.
|
||||
|
||||
Use this for spinner frames, streaming token flushes, and other
|
||||
repaints that can fire many times per second — the throttle prevents
|
||||
terminal blinking on slow/SSH connections, and the resize-recovery
|
||||
guard avoids stamping footer/status-bar chrome into scrollback while a
|
||||
SIGWINCH reflow is in flight.
|
||||
|
||||
Do NOT use this for user-blocking modal prompts (approval / clarify /
|
||||
sudo). Those are rare, one-shot, user-blocking events that must paint
|
||||
immediately; route them through ``self._app.invalidate()`` directly, the
|
||||
same way the modal key-binding handlers already do. Sending a modal's
|
||||
entry paint through this throttle lets an unrelated background repaint
|
||||
within the 250ms window — or an in-flight resize — silently drop it, so
|
||||
the prompt never renders and times out unseen (#41098).
|
||||
"""
|
||||
if getattr(self, "_resize_recovery_pending", False):
|
||||
return
|
||||
now = time.monotonic()
|
||||
@@ -3487,6 +3502,24 @@ class HermesCLI:
|
||||
self._last_invalidate = now
|
||||
self._app.invalidate()
|
||||
|
||||
def _paint_now(self) -> None:
|
||||
"""Immediate, unthrottled repaint for user-blocking modal prompts.
|
||||
|
||||
Background-thread callbacks (approval / clarify / sudo) set their modal
|
||||
state then call this to make the panel visible at once. It deliberately
|
||||
bypasses the ``_invalidate`` throttle and resize-recovery guard — a
|
||||
modal the user is actively waiting on must never be dropped — mirroring
|
||||
the direct ``event.app.invalidate()`` the modal key-binding handlers
|
||||
already use. See ``_invalidate`` for why the throttle must not gate
|
||||
these paints (#41098).
|
||||
"""
|
||||
app = getattr(self, "_app", None)
|
||||
if app is not None:
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _force_full_redraw(self) -> None:
|
||||
"""Force a clean full-screen repaint of the prompt_toolkit UI.
|
||||
|
||||
@@ -11801,18 +11834,15 @@ class HermesCLI:
|
||||
# Open-ended questions skip straight to freetext input
|
||||
self._clarify_freetext = is_open_ended
|
||||
|
||||
# Trigger prompt_toolkit repaint from this (non-main) thread
|
||||
self._invalidate()
|
||||
# Trigger an immediate prompt_toolkit repaint from this (non-main)
|
||||
# thread. Modal prompts must paint at once and must not be gated by the
|
||||
# _invalidate throttle / resize guard — see _paint_now / _invalidate (#41098).
|
||||
self._paint_now()
|
||||
|
||||
# Poll for the user's response. The countdown in the hint line
|
||||
# updates on each invalidate — but frequent repaints cause visible
|
||||
# flicker in some terminals (Kitty, ghostty). We only refresh the
|
||||
# countdown every 5 s; selection changes (↑/↓) trigger instant
|
||||
# Poll for the user's response. The countdown in the hint line
|
||||
# updates on each invalidate — but frequent repaints cause visible
|
||||
# flicker in some terminals (Kitty, ghostty). We only refresh the
|
||||
# countdown every 5 s; selection changes (↑/↓) trigger instant
|
||||
# repaints via the key bindings.
|
||||
# Poll for the user's response. The countdown in the hint line updates
|
||||
# on each repaint; refresh it once a second so the timer stays visible
|
||||
# while we wait. Selection changes (↑/↓) trigger instant repaints via
|
||||
# the key bindings.
|
||||
_last_countdown_refresh = _time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
@@ -11823,20 +11853,16 @@ class HermesCLI:
|
||||
remaining = self._clarify_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
# Only repaint every 5 s for the countdown — avoids flicker
|
||||
now = _time.monotonic()
|
||||
if now - _last_countdown_refresh >= 5.0:
|
||||
if now - _last_countdown_refresh >= 1.0:
|
||||
_last_countdown_refresh = now
|
||||
self._invalidate()
|
||||
if now - _last_countdown_refresh >= 5.0:
|
||||
_last_countdown_refresh = now
|
||||
self._invalidate()
|
||||
self._paint_now()
|
||||
|
||||
# Timed out — tear down the UI and let the agent decide
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
self._clarify_deadline = 0
|
||||
self._invalidate()
|
||||
self._paint_now()
|
||||
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
|
||||
return (
|
||||
"The user did not provide a response within the time limit. "
|
||||
@@ -11862,7 +11888,9 @@ class HermesCLI:
|
||||
}
|
||||
self._sudo_deadline = _time.monotonic() + timeout
|
||||
|
||||
self._invalidate()
|
||||
# Modal prompt — paint immediately, bypassing the throttle/resize guard
|
||||
# so the prompt can't be dropped and time out unseen (#41098).
|
||||
self._paint_now()
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -11870,7 +11898,7 @@ class HermesCLI:
|
||||
self._sudo_state = None
|
||||
self._sudo_deadline = 0
|
||||
self._restore_modal_input_snapshot()
|
||||
self._invalidate()
|
||||
self._paint_now()
|
||||
if result:
|
||||
_cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}")
|
||||
else:
|
||||
@@ -11880,12 +11908,12 @@ class HermesCLI:
|
||||
remaining = self._sudo_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
self._invalidate()
|
||||
self._paint_now()
|
||||
|
||||
self._sudo_state = None
|
||||
self._sudo_deadline = 0
|
||||
self._restore_modal_input_snapshot()
|
||||
self._invalidate()
|
||||
self._paint_now()
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
|
||||
return ""
|
||||
|
||||
@@ -11919,7 +11947,12 @@ class HermesCLI:
|
||||
}
|
||||
self._approval_deadline = _time.monotonic() + timeout
|
||||
|
||||
self._invalidate()
|
||||
# Modal prompt — paint immediately, bypassing the throttle/resize
|
||||
# guard. A throttled paint here can be silently dropped (250ms
|
||||
# window collision or in-flight resize), leaving the panel unseen so
|
||||
# the command is denied on timeout without the user ever seeing it
|
||||
# (#41098). The countdown refreshes below paint the same way.
|
||||
self._paint_now()
|
||||
|
||||
_last_countdown_refresh = _time.monotonic()
|
||||
while True:
|
||||
@@ -11927,20 +11960,20 @@ class HermesCLI:
|
||||
result = response_queue.get(timeout=1)
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._invalidate()
|
||||
self._paint_now()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = self._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
now = _time.monotonic()
|
||||
if now - _last_countdown_refresh >= 5.0:
|
||||
if now - _last_countdown_refresh >= 1.0:
|
||||
_last_countdown_refresh = now
|
||||
self._invalidate()
|
||||
self._paint_now()
|
||||
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._invalidate()
|
||||
self._paint_now()
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
|
||||
@@ -12198,7 +12231,9 @@ class HermesCLI:
|
||||
self._secret_state["response_queue"].put(value)
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._invalidate()
|
||||
# Modal teardown — paint directly so the secret panel clears at once and
|
||||
# isn't held by the _invalidate throttle/resize guard (#41098).
|
||||
self._paint_now()
|
||||
|
||||
def _cancel_secret_capture(self) -> None:
|
||||
self._submit_secret_response("")
|
||||
|
||||
Reference in New Issue
Block a user