fix(cli): paint approval/clarify/sudo/secret modal prompts directly, not via the throttle (#41098)#41155
Conversation
|
Positive verification — modal paint bypass is correct and addresses a real UX regression. Reviewed the diff for
No issues found. The fix is well-scoped and the tests prove the regression cannot recur. |
|
Thanks for carrying this forward and for the cleaner implementation — |
4433d8d to
51a4952
Compare
…not via the throttle (NousResearch#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 NousResearch#41116; @islam666 independently reached the same direct-invalidate approach in NousResearch#41166; original report NousResearch#41098 by @jodonnel.
51a4952 to
8e71b51
Compare
Summary
Fixes #41098 — in classic CLI mode (
hermes chat, not--tui) the dangerous-command approval prompt never renders. The user sees⏱ Timeout — denying commandafter 60s without ever seeing the panel, makingapprovals.mode: manualunusable. The same defect silently affects the clarify prompt (120s → agent decides alone), the sudo password prompt (45s → continues without sudo), and the secret-capture teardown.Supersedes #41116, #41141, and #41166 — see Credit.
Root cause
The approval, clarify, sudo, and secret prompts run their wait loop on the agent/background thread: they set modal state that a
ConditionalContainerfilter reads, then callself._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, theConditionalContainernever re-evaluates, and the prompt times out unseen.The throttle never belonged on these paths:
self._app.invalidate()and worked._invalidate().5e92b6780) removed an idle 1Hz repaint that had been masking dropped modal paints, surfacing the bug.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 through it — matching the key-handler idiom. Clean two-rule partition:self._invalidate()(throttled)self._app.invalidate()directly (_paint_now())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 (no new parameter); its docstring now states it's for high-frequency background repaints only. 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 and silently defeat the anti-flicker protection.Also: countdown refresh cadence tightened 5s → 1s so the timer stays visible while waiting, and a copy-pasted duplicate countdown block in
_clarify_callbackis removed.Why a new PR (not the
force=Trueapproach of #41116/#41141/#41166)The earlier PRs fixed the same bug by bypassing the throttle either via a
force=Trueflag threaded through_invalidate(#41116/#41141) or by inlining a guardedapp.invalidate()at each approval site (#41166). All work, but a throttle with a "skip the throttle" flag is semantically a directinvalidate()through a wrapper, and the flag/inline-duplication is an attractive nuisance for hot paths. Painting directly through one small_paint_now()helper (this PR) is the idiom the key-handlers already use, touches no shared primitive, covers all four modals, and is strictly less surface. #41166 also added an audible-bell side effect and only fixed approval.Tests
tests/cli/test_cli_approval_ui.py— newTestModalPaintNow:test_paint_now_bypasses_throttle_and_resize_guard/test_paint_now_no_app_is_safe— direct helper behavior.test_{approval,clarify,sudo}_prompt_paints_under_both_gates— drives each callback on a background thread with_resize_recovery_pending=TrueAND a recent_last_invalidateinside the throttle window, asserts the panel paints on entry and (approval/sudo) repaints on teardown.test_secret_response_teardown_paints— secret panel clears via_paint_now._invalidate().tests/cli/suite green (900);ruffclean.Credit
invalidate()approach in fix(cli): bypass _invalidate() throttle for approval panel render (#41098) #41166.Closes #41098