mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
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:
parent
7d86178cf5
commit
1b04e4ede5
2 changed files with 106 additions and 8 deletions
67
cli.py
67
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."""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue