From e2b2d48610263bfc695eaa250e9a71007f1b48cb Mon Sep 17 00:00:00 2001 From: vominh1919 Date: Sun, 10 May 2026 14:09:22 +0700 Subject: [PATCH] fix(cli): preserve startup banner on terminal resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli.py | 24 +++++++++++++++++++++--- tests/cli/test_cli_force_redraw.py | 30 +++++++++++++++--------------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/cli.py b/cli.py index 1f167f61cf9..72ffd0b1708 100644 --- a/cli.py +++ b/cli.py @@ -2703,9 +2703,27 @@ class HermesCLI: pass def _recover_after_resize(self, app, original_on_resize) -> None: - """Recover a resized classic CLI without desynchronizing cursor state.""" - self._clear_prompt_toolkit_screen(app, rebuild_scrollback=True) - _replay_output_history() + """Recover a resized classic CLI without desynchronizing cursor state. + + 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() def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None: diff --git a/tests/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index 4c7197ad94a..ba5b0a75534 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -71,32 +71,32 @@ class TestForceFullRedraw: "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() - out = app.renderer.output 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") - 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") bare_cli._recover_after_resize(app, original_on_resize) assert events == [ - "reset_attrs", - "erase", - ("raw", "\x1b[3J"), - "home", - "flush", "renderer_reset", - "replay", + "invalidate", "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): app = MagicMock()