mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
59da8ec4ec
commit
e2b2d48610
2 changed files with 36 additions and 18 deletions
24
cli.py
24
cli.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue