Skip to content

fix(cli): paint approval/clarify/sudo/secret modal prompts directly, not via the throttle (#41098)#41155

Merged
kshitijk4poor merged 1 commit into
NousResearch:mainfrom
kshitijk4poor:fix/cli-modal-direct-invalidate-41098
Jun 8, 2026
Merged

fix(cli): paint approval/clarify/sudo/secret modal prompts directly, not via the throttle (#41098)#41155
kshitijk4poor merged 1 commit into
NousResearch:mainfrom
kshitijk4poor:fix/cli-modal-direct-invalidate-41098

Conversation

@kshitijk4poor

@kshitijk4poor kshitijk4poor commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #41098 — in classic CLI mode (hermes chat, not --tui) the dangerous-command approval prompt never renders. The user sees ⏱ Timeout — denying command after 60s without ever seeing the panel, making approvals.mode: manual unusable. 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 ConditionalContainer 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 (fix(cli): throttle UI invalidate to prevent terminal blinking on SSH #284) blanket-replaced every invalidate — including these rare, one-shot, user-blocking modal paints — with the throttled _invalidate().
  • A later commit (5e92b6780) removed an idle 1Hz repaint that had been masking dropped modal paints, surfacing the bug.
  • 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 through it — matching the key-handler idiom. Clean two-rule partition:

Path Repaint
spinner / streaming (high-frequency background) self._invalidate() (throttled)
modal prompts + key handlers (rare / interactive) 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_callback is removed.

Why a new PR (not the force=True approach of #41116/#41141/#41166)

The earlier PRs fixed the same bug by bypassing the throttle either via a force=True flag threaded through _invalidate (#41116/#41141) or by inlining a guarded app.invalidate() at each approval site (#41166). All work, but a throttle with a "skip the throttle" flag is semantically a direct invalidate() 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 — new TestModalPaintNow:

  • 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=True AND a recent _last_invalidate inside 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.
  • Each modal test was verified to fail if its paint is reverted to _invalidate().
  • 17 in-file tests pass; full tests/cli/ suite green (900); ruff clean.

Credit

Closes #41098

@liuhao1024

Copy link
Copy Markdown
Contributor

Positive verification — modal paint bypass is correct and addresses a real UX regression.

Reviewed the diff for cli.py and tests/cli/test_cli_approval_ui.py.

  1. Root cause is well-diagnosed. The throttled _invalidate() silently drops repaints on a 250ms window collision or while a SIGWINCH resize is in flight (_resize_recovery_pending = True). Modal prompts (approval/clarify/sudo) are one-shot, user-blocking events — a dropped paint means the user never sees the prompt and it times out silently (Approval panel never renders — _invalidate() throttle drops redraw #41098).

  2. _paint_now() is the right fix. It calls self._app.invalidate() directly, bypassing both the throttle interval check and the resize-recovery guard. The getattr(self, "_app", None) guard handles the edge case where _app is torn down. The try/except is appropriate for a paint path that should never crash the event loop.

  3. All three modal callbacks are updated. _approval_callback, _clarify_callback, and _sudo_password_callback now use _paint_now() for initial paint, countdown refreshes, and cleanup repaints. No stale _invalidate() calls remain in these paths.

  4. Countdown refresh interval reduced from 5s to 1s. This is safe because _paint_now() is unthrottled and lightweight — a direct invalidate() call. The 5s interval was chosen to avoid flicker from the throttled path, which no longer applies.

  5. Test coverage is adversarial by design. The _make_real_paint_cli_stub() sets both gates (_resize_recovery_pending=True and _last_invalidate inside the throttle window) to ensure _invalidate() would be suppressed, then verifies _paint_now() paints through. The _drive() helper runs modal callbacks on a background thread and asserts invalidate was called, matching the real threading model.

  6. No side effects on non-modal paths. _invalidate() is unchanged — spinner frames, streaming token flushes, and other high-frequency repaints still use the throttle. Only the three modal callbacks switched to _paint_now().

No issues found. The fix is well-scoped and the tests prove the regression cannot recur.

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists labels Jun 7, 2026
@sanidhyasin

Copy link
Copy Markdown
Contributor

Thanks for carrying this forward and for the cleaner implementation — _paint_now() is the right call, reads better than my throttle-bypass flag. Nice catch extending it to the clarify and sudo prompts too. Appreciate the credit!

@kshitijk4poor kshitijk4poor force-pushed the fix/cli-modal-direct-invalidate-41098 branch from 4433d8d to 51a4952 Compare June 7, 2026 19:11
…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.
@kshitijk4poor kshitijk4poor force-pushed the fix/cli-modal-direct-invalidate-41098 branch from 51a4952 to 8e71b51 Compare June 7, 2026 19:17
@kshitijk4poor kshitijk4poor changed the title fix(cli): paint approval/clarify/sudo modal prompts directly, not via the throttle (#41098) fix(cli): paint approval/clarify/sudo/secret modal prompts directly, not via the throttle (#41098) Jun 7, 2026
@kshitijk4poor kshitijk4poor merged commit 4107076 into NousResearch:main Jun 8, 2026
23 checks passed
@kshitijk4poor kshitijk4poor deleted the fix/cli-modal-direct-invalidate-41098 branch June 8, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approval panel never renders — _invalidate() throttle drops redraw

4 participants