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

View file

@ -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()