fix(cli): status bar no longer stays hidden after resize during idle (#49105)

The classic CLI status bar could vanish for the rest of a session: any
terminal reflow (SIGWINCH from a tmux pane change, SSH window restore, font
zoom) set _status_bar_suppressed_after_resize=True, but the flag was ONLY
cleared on the next *submitted* user input. Resize then sit idle and the
bottom chrome rendered at height 0 on every repaint — even with the
refresh clock ticking — so the bar was gone until you typed and hit enter.

Fix: _recover_after_resize now schedules a debounced unsuppress timer that
clears the flag and repaints once the reflow settles (~0.35s), so the bar
returns on its own during idle. The next-submit clear stays as a fast path.
Fails open: any error in scheduling clears the flag immediately rather than
leaving the bar stuck hidden.
This commit is contained in:
Teknium 2026-06-19 07:53:58 -07:00 committed by GitHub
parent 7d86178cf5
commit 1b04e4ede5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 106 additions and 8 deletions

67
cli.py
View file

@ -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."""

View file

@ -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.