{"repo":"NousResearch/hermes-agent","public_id":"22999","display_label":"CLI terminal resize corrupts startup banner display","summary":"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.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":25227},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-14T22:14:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":22999,"pr_number":25227,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":22999,"pr_number":25974,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":22999,"title":"Classic CLI resize clears startup banner/tool summary after terminal maximize","url":null,"state":"closed"},{"kind":"pr","number":23048,"title":"fix(cli): preserve startup banner on terminal resize","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e2b2d4861","number":0},{"kind":"pr","value":"#25227","number":25227}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":23294,"title":"fix(cli): preserve startup scrollback on resize","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e2b2d4861","number":0},{"kind":"pr","value":"#25227","number":25227},{"kind":"pr","value":"#25974","number":25974}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":25227,"title":"fix(cli): preserve startup banner on terminal resize","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_cli_force_redraw.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -2703,9 +2703,27 @@ def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False)\n             pass\n \n     def _recover_after_resize(self, app, original_on_resize) -> None:\n-        \"\"\"Recover a resized classic CLI without desynchronizing cursor state.\"\"\"\n-        self._clear_prompt_toolkit_screen(app, rebuild_scrollback=True)\n-        _replay_output_history()\n+        \"\"\"Recover a resized classic CLI without desynchronizing cursor state.\n+\n+        Unlike _force_full_redraw, we do NOT clear the physical screen or\n+        scrollback here.  The startup banner and tool summary are printed\n+        before prompt_toolkit owns the live chrome, so they live in normal\n+        terminal scrollback.  Erasing the screen on SIGWINCH removes that\n+        startup UI and ``_replay_output_history`` cannot reconstruct it\n+        (the banner was never added to ``_OUTPUT_HISTORY``).\n+\n+        Instead we just reset prompt_toolkit's renderer cache so the next\n+        incremental redraw starts from a clean slate, then let\n+        ``original_on_resize`` recalculate layout for the new size.\n+        \"\"\"\n+        try:\n+            app.renderer.reset(leave_alternate_screen=False)\n+        except Exception:\n+            pass\n+        try:\n+            app.invalidate()\n+        except Exception:\n+            pass\n         original_on_resize()\n \n     def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:"]},{"file":"tests/cli/test_cli_force_redraw.py","hunks":["@@ -71,32 +71,32 @@ def test_sends_full_clear_replays_then_invalidates(self, bare_cli, monkeypatch):\n             \"invalidate\",\n         ]\n \n-    def test_resize_rebuilds_scrollback_before_prompt_toolkit_redraw(self, bare_cli, monkeypatch):\n+    def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):\n+        \"\"\"Resize recovery must NOT erase screen or scrollback.\n+\n+        The startup banner lives in normal terminal scrollback (printed\n+        before prompt_toolkit owns the chrome).  Clearing scrollback on\n+        SIGWINCH removes it and ``_replay_output_history`` cannot\n+        reconstruct it.  The fix is to only reset the renderer cache and\n+        let ``original_on_resize`` recalculate layout.\n+        \"\"\"\n         app = MagicMock()\n-        out = app.renderer.output\n         events = []\n-        out.reset_attributes.side_effect = lambda: events.append(\"reset_attrs\")\n-        out.erase_screen.side_effect = lambda: events.append(\"erase\")\n-        out.write_raw.side_effect = lambda text: events.append((\"raw\", text))\n-        out.cursor_goto.side_effect = lambda *_: events.append(\"home\")\n-        out.flush.side_effect = lambda: events.append(\"flush\")\n         app.renderer.reset.side_effect = lambda **_: events.append(\"renderer_reset\")\n-        monkeypatch.setattr(cli_mod, \"_replay_output_history\", lambda: events.append(\"replay\"))\n+        app.invalidate.side_effect = lambda: events.append(\"invalidate\")\n         original_on_resize = lambda: events.append(\"original_resize\")\n \n         bare_cli._recover_after_resize(app, original_on_resize)\n \n         assert events == [\n-            \"reset_attrs\",\n-            \"erase\",\n-            (\"raw\", \"\\x1b[3J\"),\n-            \"home\",\n-            \"flush\",\n             \"renderer_reset\",\n-            \"replay\",\n+            \"invalidate\",\n             \"original_resize\",\n         ]\n-        app.invalidate.assert_not_called()\n+        # Must NOT clear the screen or scrollback — those destroy the banner.\n+        app.renderer.output.erase_screen.assert_not_called()\n+        app.renderer.output.write_raw.assert_not_called()\n+        app.renderer.output.cursor_goto.assert_not_called()\n \n     def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli):\n         app = MagicMock()\n"]}]},{"kind":"pr","number":25974,"title":"fix(cli): preserve ANSI styling in resize replay history (salvage #24292)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","scripts/release.py","tests/cli/test_cprint_bg_thread.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -1415,9 +1415,6 @@ def _render_final_assistant_content(text: str, mode: str = \"render\"):\n _OUTPUT_HISTORY_SUPPRESSED = False\n _OUTPUT_HISTORY_MAX_LINES = 200\n _OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)\n-_ANSI_CONTROL_RE = re.compile(\n-    r\"\\x1b(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^\\x07]*(?:\\x07|\\x1b\\\\))\"\n-)\n \n \n def _coerce_output_history_limit(value) -> int:","@@ -1459,10 +1456,10 @@ def _record_output_history_entry(entry) -> None:\n def _record_output_history(text: str) -> None:\n     if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:\n         return\n-    clean = _ANSI_CONTROL_RE.sub(\"\", str(text)).replace(\"\\r\", \"\").rstrip(\"\\n\")\n-    if not clean:\n+    normalized = str(text).replace(\"\\r\", \"\").rstrip(\"\\n\")\n+    if not normalized:\n         return\n-    for line in clean.splitlines():\n+    for line in normalized.splitlines():\n         _record_output_history_entry(line)\n \n "]},{"file":"scripts/release.py","hunks":["@@ -146,6 +146,7 @@\n     \"[redacted:email]\": \"SandroHub013\",\n     \"[redacted:email]\": \"maciekczech\",\n     \"[redacted:email]\": \"LeonSGP43\",\n+    \"[redacted:email]\": \"LeonSGP43\",\n     \"[redacted:email]\": \"zeejaytan\",\n     \"[redacted:email]\": \"Aslaaen\",\n     \"[redacted:email]\": \"Tkander1715\","]},{"file":"tests/cli/test_cprint_bg_thread.py","hunks":["@@ -215,13 +215,15 @@ def find_spec(self, name, path=None, target=None):\n     assert direct_prints == [\"fallback2\"]\n \n \n-def test_output_history_strips_ansi_and_keeps_recent_lines():\n+def test_output_history_preserves_ansi_and_keeps_recent_lines():\n     cli._configure_output_history(True, 10)\n \n     for idx in range(12):\n         cli._record_output_history(f\"\\x1b[31mline-{idx}\\x1b[0m\")\n \n-    assert list(cli._OUTPUT_HISTORY) == [f\"line-{idx}\" for idx in range(2, 12)]\n+    assert list(cli._OUTPUT_HISTORY) == [\n+        f\"\\x1b[31mline-{idx}\\x1b[0m\" for idx in range(2, 12)\n+    ]\n \n \n 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\n     assert printed == [\"first line\\nsecond line\\nthird line\\nfourth line\"]\n \n \n+def test_chat_console_records_rich_ansi_for_resize_replay(monkeypatch):\n+    cli._configure_output_history(True, 10)\n+    monkeypatch.setattr(cli, \"_pt_print\", lambda *_args, **_kwargs: None)\n+\n+    cli.ChatConsole().print(\"[bold red]Hello[/]\")\n+\n+    assert cli._OUTPUT_HISTORY\n+    assert any(\"\\x1b[\" in line for line in cli._OUTPUT_HISTORY)\n+\n+\n def test_suspend_output_history_blocks_recording():\n     cli._configure_output_history(True, 10)\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}