fix(cli): render approval/clarify/sudo modal prompts past _invalidate throttle + resize guard (#41098)#41141
Closed
kshitijk4poor wants to merge 2 commits into
Closed
Conversation
…ousResearch#41098) The dangerous-command approval prompt never rendered in classic CLI mode. `_approval_callback` set `_approval_state` and called the throttled `_invalidate()` (250ms min interval). When any other UI event — a spinner frame, an output flush — had invalidated within the previous window, this redraw was silently dropped, the `ConditionalContainer` wrapping the panel never re-evaluated, and the user saw nothing until the 60s timeout denied the command. The retry refresh only fired every 5s, far too slow to recover. The approval prompt is a user-blocking modal: it must paint immediately regardless of throttle state. Pass `min_interval=0.0` on every redraw in the loop — the same throttle-bypass already used elsewhere in the file — and tighten the countdown refresh to 1s so the timer stays visible while waiting. Adds a regression test driving the real throttled `_invalidate` with a recent invalidation in the window; it fails without the bypass and passes with it.
…ery (NousResearch#41098) The salvaged approval fix (sanidhyasin) makes the dangerous-command approval panel paint past the _invalidate() throttle. But the two sibling user-blocking modals regressed identically and from the same root cause: - _clarify_callback (clarify tool prompt, 120s timeout) - _sudo_password_callback (sudo password prompt, 45s timeout) Both set their modal state on the agent/background thread and then relied on a single throttled self._invalidate() to paint. The approval, clarify, and sudo prompts all originally used direct self._app.invalidate() and worked. PR NousResearch#284 (b603b6e) blanket-replaced those with the throttled _invalidate(), making the modal paints droppable — but the bug stayed masked because the spinner loop's idle branch called _invalidate( min_interval=1.0) every second, repainting a dropped modal within ~1s. Commit 5e92b67 ('stop idle CLI redraws') removed that idle repaint to fix tmux/Ghostty viewport-restore flicker, which unmasked the bug: a dropped modal paint now never recovers and the prompt times out unseen. Re-adding the idle repaint would reintroduce the flicker 5e92b67 fixed, so the correct fix is to make the modal-entry paints unconditional via the established min_interval=0.0 bypass idiom. Applied to clarify + sudo to match the approval fix: initial paint, response-received, in-wait countdown (cadence 5s->1s for clarify), and timeout teardown. Resize-recovery edge case: even min_interval=0.0 did not bypass the _resize_recovery_pending early-return added by 76074d9 — so during a (possibly held) drag-resize the modal could stay invisible until it timed out. Add a keyword-only force=True to _invalidate that bypasses BOTH the throttle AND the resize guard, and use it for the three modal *entry* paints only. This is safe: a modal overlay is live prompt_toolkit chrome (not scrollback output), so painting it slightly early during a resize is harmless, and _recover_after_resize issues its own unconditional app.invalidate() when the debounce settles. The resize guard still suppresses ordinary repaints (status bar / footer chrome) as before. Also removes a copy-paste-duplicated countdown-refresh block in _clarify_callback (the same 5s gate appeared twice). Adds regression tests: mirror dropped-paint tests for clarify and sudo; an approval-during-resize test; and a direct _invalidate force-vs-gates test. Each fails if its corresponding bypass is reverted. 16 tests pass; full tests/cli suite green (899).
53fc351 to
9142e55
Compare
Collaborator
Author
|
Superseding with #41155, a cleaner from-scratch implementation. Instead of adding a |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Salvages #41116 (sanidhyasin) onto current
mainand extends the fix to the two sibling modals that regressed from the same root cause, fixing #41098.In classic CLI mode (
hermes chat, not--tui), the dangerous-command approval panel never renders: the user just sees⏱ Timeout — denying commandafter 60s, makingapprovals.mode: manualeffectively unusable. The same defect silently affects the clarify prompt (120s timeout → agent decides alone) and the sudo password prompt (45s timeout → continues without sudo).Root cause (regression archaeology)
The approval, clarify, and sudo prompts all set their modal state on the agent/background thread, then paint via a single redraw call. All three originally used
self._app.invalidate()directly and worked.b603b6e1c(PR fix(cli): throttle UI invalidate to prevent terminal blinking on SSH #284, Mar 2) — added a 250ms redraw throttle (_invalidate(min_interval=0.25)) to stop terminal blinking on SSH, and blanket-replaced everyself._app.invalidate()with the throttledself._invalidate()— including the modal-entry paints. This made the modal paints droppable: if any other UI event (spinner frame, output flush) invalidated within the prior 250ms, the modal's paint is silently dropped and theConditionalContainernever re-evaluates. But the bug stayed masked —_invalidate(min_interval=1.0)every second, so a dropped modal paint was repainted within ~1s.5e92b6780(Apr 25) — removed the idle 1Hz repaint (to stop background redraws fighting tmux/Ghostty/cmux viewport restoration). Reasonable fix, but it deleted the safety net: a dropped modal paint now never recovers, so the prompt times out unseen. This is when Approval panel never renders — _invalidate() throttle drops redraw #41098 became user-visible.Re-adding the idle repaint would reintroduce the flicker
5e92b6780fixed. The correct fix is to make the modal-entry paints unconditional via the establishedmin_interval=0.0bypass idiom (already used ~8× incli.py) — strictly better than the old ~1s-delayed recovery, and it doesn't reintroduce any background redraw.Changes
_approval_callback(sanidhyasin's commit): all 4self._invalidate()calls →min_interval=0.0; countdown cadence 5s → 1s._clarify_callback(follow-up): same bypass on initial paint, in-wait countdown (5s → 1s), and timeout teardown. Also removes a copy-paste-duplicated countdown-refresh block (the same 5s gate appeared twice)._sudo_password_callback(follow-up): same bypass on initial paint, response-received, in-wait, and timeout teardown.min_interval=0.0did not bypass the_resize_recovery_pendingearly-return (added by76074d9eeto stop footer chrome being stamped into scrollback mid-reflow). So during a held drag-resize the modal could stay invisible until it timed out. A keyword-onlyforce=Truewas added to_invalidatethat bypasses both the throttle and the resize guard, used for the three modal entry paints only. Safe because a modal overlay is live prompt_toolkit chrome (not scrollback output) and_recover_after_resizeissues its own unconditionalapp.invalidate()when the debounce settles. The resize guard still suppresses ordinary repaints as before._computer_use_approval_callbackdelegates to_approval_callback, so it's fixed automatically. The secret-capture modal already uses directapp.invalidate()and was never affected.Tests
tests/cli/test_cli_approval_ui.py:test_approval_callback_renders_despite_recent_invalidate(from fix(cli): force approval panel redraw past the _invalidate throttle (#41098) #41116) — drives the real throttled_invalidatewith a recent invalidation in-window, asserts the panel still paints.test_clarify_callback_renders_despite_recent_invalidate(new) — mirror for clarify.test_sudo_callback_renders_despite_recent_invalidate(new) — mirror for sudo.test_approval_callback_renders_during_resize_recovery(new) — modal still paints with_resize_recovery_pending=True.test_invalidate_force_bypasses_resize_and_throttle_gates(new) —force=Truebypasses both gates; an unforced call respects both.clarify panel redraw was dropped by the throttle; resize-guard revert → both resize tests fail).tests/cli/suite green (899);ruffclean.Credit
Original issue #41098 reported by @jodonnel.
Closes #41116
Closes #41098