Skip to content

fix(cli): render approval/clarify/sudo modal prompts past _invalidate throttle + resize guard (#41098)#41141

Closed
kshitijk4poor wants to merge 2 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/cli-modal-redraw-throttle-41116
Closed

fix(cli): render approval/clarify/sudo modal prompts past _invalidate throttle + resize guard (#41098)#41141
kshitijk4poor wants to merge 2 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/cli-modal-redraw-throttle-41116

Conversation

@kshitijk4poor

@kshitijk4poor kshitijk4poor commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Salvages #41116 (sanidhyasin) onto current main and 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 command after 60s, making approvals.mode: manual effectively 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.

  1. 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 every self._app.invalidate() with the throttled self._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 the ConditionalContainer never re-evaluates. But the bug stayed masked
  2. the spinner loop's idle branch called _invalidate(min_interval=1.0) every second, so a dropped modal paint was repainted within ~1s.
  3. 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 5e92b6780 fixed. The correct fix is to make the modal-entry paints unconditional via the established min_interval=0.0 bypass idiom (already used ~8× in cli.py) — strictly better than the old ~1s-delayed recovery, and it doesn't reintroduce any background redraw.

Changes

  • _approval_callback (sanidhyasin's commit): all 4 self._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.
  • Resize-recovery edge case (follow-up): even min_interval=0.0 did not bypass the _resize_recovery_pending early-return (added by 76074d9ee to 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-only force=True was added to _invalidate that 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_resize issues its own unconditional app.invalidate() when the debounce settles. The resize guard still suppresses ordinary repaints as before.
  • _computer_use_approval_callback delegates to _approval_callback, so it's fixed automatically. The secret-capture modal already uses direct app.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 _invalidate with 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=True bypasses both gates; an unforced call respects both.
  • Each was verified to fail if its bypass is reverted (e.g. clarify revert → clarify panel redraw was dropped by the throttle; resize-guard revert → both resize tests fail).
  • 16 passed; full tests/cli/ suite green (899); ruff clean.

Credit

Original issue #41098 reported by @jodonnel.

Closes #41116
Closes #41098

sanidhyasin and others added 2 commits June 7, 2026 15:13
…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).
@kshitijk4poor kshitijk4poor force-pushed the salvage/cli-modal-redraw-throttle-41116 branch from 53fc351 to 9142e55 Compare June 7, 2026 09:43
@kshitijk4poor kshitijk4poor changed the title fix(cli): render approval/clarify/sudo modal prompts past the _invalidate throttle (#41098) fix(cli): render approval/clarify/sudo modal prompts past _invalidate throttle + resize guard (#41098) Jun 7, 2026
@kshitijk4poor

Copy link
Copy Markdown
Collaborator Author

Superseding with #41155, a cleaner from-scratch implementation. Instead of adding a force=True throttle-bypass flag threaded through ~12 call sites, #41155 routes the three modal prompts through a small _paint_now() helper that paints directly via app.invalidate() — the same idiom the modal key-binding handlers already use. The throttle (_invalidate) is left untouched. Same fix (approval + clarify + sudo + resize-recovery edge case), smaller surface, no bypass flag to cargo-cult onto hot paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approval panel never renders — _invalidate() throttle drops redraw

2 participants