CLI terminal resize corrupts startup banner display

NousResearch/hermes-agent · cluster 22999

Data as of

Nothing open on GitHub. Fix merged in #25227. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #25227. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests address problems where resizing the terminal window corrupts, clears, or duplicates the startup banner, tool summary, and related display elements in the CLI. Fixes involve managing ANSI styling, viewport clearing, and scrollback preservation during resize events.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #25227 fix(cli): preserve startup banner on terminal resize

    merged to main

    files: cli.py, tests/cli/test_cli_force_redraw.py

    Show the diff
    • cli.py
      @@ -2703,9 +2703,27 @@ def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False)
                   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:
    • tests/cli/test_cli_force_redraw.py
      @@ -71,32 +71,32 @@ def test_sends_full_clear_replays_then_invalidates(self, bare_cli, monkeypatch):
                   "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()
  • #25974 fix(cli): preserve ANSI styling in resize replay history (salvage #24292)

    merged to main

    files: cli.py, scripts/release.py, tests/cli/test_cprint_bg_thread.py

    Show the diff
    • cli.py
      @@ -1415,9 +1415,6 @@ def _render_final_assistant_content(text: str, mode: str = "render"):
       _OUTPUT_HISTORY_SUPPRESSED = False
       _OUTPUT_HISTORY_MAX_LINES = 200
       _OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
      -_ANSI_CONTROL_RE = re.compile(
      -    r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))"
      -)
       
       
       def _coerce_output_history_limit(value) -> int:
      @@ -1459,10 +1456,10 @@ def _record_output_history_entry(entry) -> None:
       def _record_output_history(text: str) -> None:
           if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
               return
      -    clean = _ANSI_CONTROL_RE.sub("", str(text)).replace("\r", "").rstrip("\n")
      -    if not clean:
      +    normalized = str(text).replace("\r", "").rstrip("\n")
      +    if not normalized:
               return
      -    for line in clean.splitlines():
      +    for line in normalized.splitlines():
               _record_output_history_entry(line)
       
       
    • scripts/release.py
      @@ -146,6 +146,7 @@
           "[redacted:email]": "SandroHub013",
           "[redacted:email]": "maciekczech",
           "[redacted:email]": "LeonSGP43",
      +    "[redacted:email]": "LeonSGP43",
           "[redacted:email]": "zeejaytan",
           "[redacted:email]": "Aslaaen",
           "[redacted:email]": "Tkander1715",
    • tests/cli/test_cprint_bg_thread.py
      @@ -215,13 +215,15 @@ def find_spec(self, name, path=None, target=None):
           assert direct_prints == ["fallback2"]
       
       
      -def test_output_history_strips_ansi_and_keeps_recent_lines():
      +def test_output_history_preserves_ansi_and_keeps_recent_lines():
           cli._configure_output_history(True, 10)
       
           for idx in range(12):
               cli._record_output_history(f"\x1b[31mline-{idx}\x1b[0m")
       
      -    assert list(cli._OUTPUT_HISTORY) == [f"line-{idx}" for idx in range(2, 12)]
      +    assert list(cli._OUTPUT_HISTORY) == [
      +        f"\x1b[31mline-{idx}\x1b[0m" for idx in range(2, 12)
      +    ]
       
       
       def test_replay_output_history_does_not_record_replayed_lines(monkeypatch):
      @@ -277,6 +279,16 @@ def test_replay_output_history_batches_rendered_lines_into_one_print(monkeypatch
           assert printed == ["first line\nsecond line\nthird line\nfourth line"]
       
       
      +def test_chat_console_records_rich_ansi_for_resize_replay(monkeypatch):
      +    cli._configure_output_history(True, 10)
      +    monkeypatch.setattr(cli, "_pt_print", lambda *_args, **_kwargs: None)
      +
      +    cli.ChatConsole().print("[bold red]Hello[/]")
      +
      +    assert cli._OUTPUT_HISTORY
      +    assert any("\x1b[" in line for line in cli._OUTPUT_HISTORY)
      +
      +
       def test_suspend_output_history_blocks_recording():
           cli._configure_output_history(True, 10)
       

2 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1

  • #23048 fix(cli): preserve startup banner on terminal resize · merged via another change → e2b2d48, #25227
  • #23294 fix(cli): preserve startup scrollback on resize · done elsewhere → e2b2d48, #25227, #25974
Closed issues · 1
  • Closed#22999Classic CLI resize clears startup banner/tool summary after terminal maximize
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#25227, #25974
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
14 May 2026

For your agent

The same facts, typed.