diff --git a/cli.py b/cli.py index bafa80b7cef..cf4f533744d 100644 --- a/cli.py +++ b/cli.py @@ -3676,6 +3676,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._resize_recovery_lock = threading.Lock() self._resize_recovery_timer = None self._resize_recovery_pending = False + # Debounced timer that clears the post-resize suppression once the + # terminal reflow settles, so the status bar returns during idle + # without waiting for the next submitted input. + self._status_bar_unsuppress_timer = None # Background task tracking: {task_id: threading.Thread} self._background_tasks: Dict[str, threading.Thread] = {} @@ -3826,15 +3830,66 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): origin and can leave stale prompt glyphs after a narrow resize. We also flag ``_status_bar_suppressed_after_resize`` so the dynamic - status bar and input separator rules stay hidden until the next user - input. On column shrink the terminal reflows already-rendered status - bar rows into scrollback before prompt_toolkit can erase them; drawing - a fresh full-width bar immediately makes the old and new versions - look duplicated (#19280, #22976). Clearing the suppression on the - next prompt restores the bar cleanly. + status bar and input separator rules stay hidden while the terminal + reflow settles. On column shrink the terminal reflows already-rendered + status bar rows into scrollback before prompt_toolkit can erase them; + drawing a fresh full-width bar immediately makes the old and new + versions look duplicated (#19280, #22976). + + The suppression is transient: a short follow-up timer clears it and + repaints once the reflow has settled, so the bar returns on its own + during idle. Previously the flag was only cleared on the next + *submitted* user input, so a resize/reflow (tmux pane change, SSH + window restore, font zoom) followed by idle left the status bar hidden + indefinitely even while the refresh clock kept ticking (the dynamic + chrome rendered at height 0 on every repaint). The next-submit clear + at the input loop remains as a fast path. """ self._status_bar_suppressed_after_resize = True original_on_resize() + self._schedule_status_bar_unsuppress(app) + + def _schedule_status_bar_unsuppress(self, app, delay: float = 0.35) -> None: + """Clear the post-resize status-bar suppression after the reflow settles. + + Debounced: a fresh resize cancels the pending unsuppress and restarts + the timer, so a resize storm only repaints the bar once it stops. + """ + try: + old_timer = getattr(self, "_status_bar_unsuppress_timer", None) + if old_timer is not None: + try: + old_timer.cancel() + except Exception: + pass + + def _clear(): + self._status_bar_suppressed_after_resize = False + try: + app.invalidate() + except Exception: + pass + + def _fire(): + try: + loop = getattr(app, "loop", None) + except Exception: + loop = None + if loop is not None: + try: + loop.call_soon_threadsafe(_clear) + return + except Exception: + pass + _clear() + + timer = threading.Timer(delay, _fire) + timer.daemon = True + self._status_bar_unsuppress_timer = timer + timer.start() + except Exception: + # Fail open: never leave the bar stuck hidden. + self._status_bar_suppressed_after_resize = False def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None: """Debounce resize redraws so footer chrome is not stamped into scrollback.""" diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 36587bff722..e27ade6af7d 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -293,8 +293,9 @@ class TestCLIStatusBar: """When _status_bar_suppressed_after_resize is set, both rules hide. See _recover_after_resize — column shrink reflows already-rendered - bars into scrollback, so we hide the separators until the user - submits the next input, at which point the flag is cleared. + bars into scrollback, so we hide the separators while the reflow + settles, then clear the flag (either via the scheduled unsuppress + timer or the next submitted input). """ cli_obj = _make_cli() cli_obj._status_bar_suppressed_after_resize = True @@ -306,6 +307,48 @@ class TestCLIStatusBar: assert cli_obj._tui_input_rule_height("top", width=90) == 1 assert cli_obj._tui_input_rule_height("bottom", width=90) == 1 + def test_scheduled_unsuppress_clears_flag_and_repaints_without_input(self): + """The status bar returns during idle after a resize, without a keypress. + + Regression: the suppression flag was only cleared on the next + *submitted* input, so a resize/reflow followed by idle left the bar + hidden indefinitely even while the refresh clock kept ticking. The + scheduled unsuppress timer must clear the flag and invalidate the app + on its own. + """ + cli_obj = _make_cli() + cli_obj._status_bar_unsuppress_timer = None + cli_obj._status_bar_suppressed_after_resize = True + app = MagicMock() + app.loop = None # force the synchronous _clear path + + # Schedule with ~0 delay so the timer fires promptly under test. + cli_obj._schedule_status_bar_unsuppress(app, delay=0.01) + time.sleep(0.1) + + assert cli_obj._status_bar_suppressed_after_resize is False + app.invalidate.assert_called() + # Bar chrome is visible again with no submitted input. + assert cli_obj._tui_input_rule_height("top", width=90) == 1 + + def test_scheduled_unsuppress_debounces_resize_storm(self): + """A fresh resize cancels the pending unsuppress and restarts it.""" + cli_obj = _make_cli() + cli_obj._status_bar_unsuppress_timer = None + cli_obj._status_bar_suppressed_after_resize = True + app = MagicMock() + app.loop = None + + # First schedule (long delay) then a second should cancel the first. + cli_obj._schedule_status_bar_unsuppress(app, delay=5.0) + first_timer = cli_obj._status_bar_unsuppress_timer + assert first_timer is not None + cli_obj._schedule_status_bar_unsuppress(app, delay=0.01) + assert first_timer is not cli_obj._status_bar_unsuppress_timer + assert not first_timer.is_alive() or first_timer.finished.is_set() + time.sleep(0.1) + assert cli_obj._status_bar_suppressed_after_resize is False + def test_scrollback_box_width_returns_viewport_width(self): """Decorative scrollback boxes use the full viewport width.