fix(cli): preserve startup banner on terminal resize

Recover from SIGWINCH without clearing the physical screen or scrollback
buffer. The startup banner and tool summary are printed before
prompt_toolkit owns the live chrome, so they live in normal terminal
scrollback. Calling erase_screen() + \x1b[3J] on every resize removed
that UI permanently — _replay_output_history cannot reconstruct it
because the banner was never added to _OUTPUT_HISTORY.

Instead, just reset prompt_toolkit's renderer cache and invalidate so
the next incremental redraw starts from a clean slate, then let the
original on_resize handler recalculate layout for the new terminal
size. This matches the behaviour of bash/zsh/fish on SIGWINCH.

Fixes NousResearch/hermes-agent#22999
This commit is contained in:
vominh1919 2026-05-10 14:09:22 +07:00 committed by Teknium
parent 59da8ec4ec
commit e2b2d48610
2 changed files with 36 additions and 18 deletions

24
cli.py
View file

@ -2703,9 +2703,27 @@ class HermesCLI:
pass pass
def _recover_after_resize(self, app, original_on_resize) -> None: def _recover_after_resize(self, app, original_on_resize) -> None:
"""Recover a resized classic CLI without desynchronizing cursor state.""" """Recover a resized classic CLI without desynchronizing cursor state.
self._clear_prompt_toolkit_screen(app, rebuild_scrollback=True)
_replay_output_history() Unlike _force_full_redraw, we do NOT clear the physical screen or
scrollback here. The startup banner and tool summary are printed
before prompt_toolkit owns the live chrome, so they live in normal
terminal scrollback. Erasing the screen on SIGWINCH removes that
startup UI and ``_replay_output_history`` cannot reconstruct it
(the banner was never added to ``_OUTPUT_HISTORY``).
Instead we just reset prompt_toolkit's renderer cache so the next
incremental redraw starts from a clean slate, then let
``original_on_resize`` recalculate layout for the new size.
"""
try:
app.renderer.reset(leave_alternate_screen=False)
except Exception:
pass
try:
app.invalidate()
except Exception:
pass
original_on_resize() original_on_resize()
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None: def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:

View file

@ -71,32 +71,32 @@ class TestForceFullRedraw:
"invalidate", "invalidate",
] ]
def test_resize_rebuilds_scrollback_before_prompt_toolkit_redraw(self, bare_cli, monkeypatch): def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
"""Resize recovery must NOT erase screen or scrollback.
The startup banner lives in normal terminal scrollback (printed
before prompt_toolkit owns the chrome). Clearing scrollback on
SIGWINCH removes it and ``_replay_output_history`` cannot
reconstruct it. The fix is to only reset the renderer cache and
let ``original_on_resize`` recalculate layout.
"""
app = MagicMock() app = MagicMock()
out = app.renderer.output
events = [] events = []
out.reset_attributes.side_effect = lambda: events.append("reset_attrs")
out.erase_screen.side_effect = lambda: events.append("erase")
out.write_raw.side_effect = lambda text: events.append(("raw", text))
out.cursor_goto.side_effect = lambda *_: events.append("home")
out.flush.side_effect = lambda: events.append("flush")
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset") app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay")) app.invalidate.side_effect = lambda: events.append("invalidate")
original_on_resize = lambda: events.append("original_resize") original_on_resize = lambda: events.append("original_resize")
bare_cli._recover_after_resize(app, original_on_resize) bare_cli._recover_after_resize(app, original_on_resize)
assert events == [ assert events == [
"reset_attrs",
"erase",
("raw", "\x1b[3J"),
"home",
"flush",
"renderer_reset", "renderer_reset",
"replay", "invalidate",
"original_resize", "original_resize",
] ]
app.invalidate.assert_not_called() # Must NOT clear the screen or scrollback — those destroy the banner.
app.renderer.output.erase_screen.assert_not_called()
app.renderer.output.write_raw.assert_not_called()
app.renderer.output.cursor_goto.assert_not_called()
def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli): def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli):
app = MagicMock() app = MagicMock()