From e7bcdcc0c9b81e7338252bfdbc57e25bb9028ea3 Mon Sep 17 00:00:00 2001 From: Jackal991 Date: Wed, 23 Sep 2026 06:59:06 -0700 Subject: [PATCH] fix(cli): bound persistent_output replay to visible terminal height Replay re-printed the full 200-line _OUTPUT_HISTORY buffer on every resize/redraw, but the pre-replay clear only wipes the visible screen (CSI 2J), so each replay appended a duplicate copy of the history to scrollback. Bound the replay to the visible row count so a redraw restores the visible transcript without stacking duplicate blocks. Refs #95375 --- cli.py | 25 ++++++- tests/hermes_cli/test_cli_force_redraw.py | 79 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 9f2dc1b9c6..296ac5f899 100644 --- a/cli.py +++ b/cli.py @@ -646,8 +646,23 @@ def _suspend_output_history(): _OUTPUT_HISTORY_SUPPRESSED = old_value +def _visible_terminal_rows() -> int: + """Return the terminal's current visible row count (fallback 24).""" + try: + return shutil.get_terminal_size((80, 24)).lines + except Exception: + return 24 + + def _replay_output_history() -> None: - """Repaint recent output above the prompt after a full screen clear.""" + """Repaint recent output above the prompt after a full screen clear. + + The pre-replay clear only wipes the *visible* viewport (CSI 2J), not the + scrollback, so re-printing the full ``_OUTPUT_HISTORY`` buffer on every + resize/redraw would append a duplicate copy of the history to scrollback + (#95375). Bound the replay to the visible terminal height so a redraw + restores the visible transcript without stacking duplicate blocks. + """ global _OUTPUT_HISTORY_REPLAYING if not _OUTPUT_HISTORY_ENABLED or not _OUTPUT_HISTORY: return @@ -665,6 +680,14 @@ def _replay_output_history() -> None: lines = lines.splitlines() rendered_lines.extend(str(line) for line in lines) if rendered_lines: + # Only re-print what fits on the visible screen. The clear that + # precedes this replay wipes the viewport (CSI 2J) but not the + # scrollback, so re-printing the whole buffer would append a + # duplicate block to scrollback on every resize/redraw. Bounding + # the replay to the visible row count keeps the transcript intact + # without stacking duplicates. + visible_rows = max(1, _visible_terminal_rows()) + rendered_lines = rendered_lines[-visible_rows:] # One payload: per-line pt prints each force a sync redraw (a waterfall of old output). _pt_print(_PT_ANSI("\n".join(rendered_lines))) except Exception: diff --git a/tests/hermes_cli/test_cli_force_redraw.py b/tests/hermes_cli/test_cli_force_redraw.py index 344fa4621c..4e9942cdb8 100644 --- a/tests/hermes_cli/test_cli_force_redraw.py +++ b/tests/hermes_cli/test_cli_force_redraw.py @@ -391,6 +391,85 @@ class TestFirstSigwinchBaseline: assert getattr(bare_cli, "_last_resize_width", None) is None +class TestReplayBoundedToVisibleHeight: + """Bug #95375: persistent_output replay must not append a duplicate block + to scrollback on resize/redraw. + + The pre-replay clear only wipes the visible viewport (CSI 2J), not the + scrollback, so re-printing the full ``_OUTPUT_HISTORY`` buffer on every + resize/redraw stacked a fresh copy of the history below the old content. + The replay must be bounded to the terminal's visible row count. + """ + + def test_replay_emits_at_most_visible_rows(self, monkeypatch): + """With 200 history lines and a 10-row terminal, replay emits ~10 lines, + not all 200.""" + cli_mod._configure_output_history(True, 200) + for i in range(200): + cli_mod._record_output_history(f"history line {i}") + + printed = [] + monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed.append(x)) + monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t) + monkeypatch.setattr( + cli_mod.shutil, + "get_terminal_size", + lambda *a, **k: __import__("os").terminal_size((80, 10)), + ) + + cli_mod._replay_output_history() + + assert len(printed) == 1, "replay must emit a single ANSI payload" + replayed = printed[0].split("\n") + assert len(replayed) <= 10, ( + f"replay emitted {len(replayed)} lines, expected at most 10 " + "(visible terminal height) — full 200-line buffer was re-printed" + ) + # The LAST visible lines are replayed, not the first. + assert replayed == [f"history line {i}" for i in range(190, 200)] + + def test_replay_keeps_full_history_buffer(self, monkeypatch): + """Bounding the replay must NOT truncate _OUTPUT_HISTORY itself — the + deque keeps the full history for other consumers.""" + cli_mod._configure_output_history(True, 200) + for i in range(200): + cli_mod._record_output_history(f"history line {i}") + + monkeypatch.setattr(cli_mod, "_pt_print", lambda x: None) + monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t) + monkeypatch.setattr( + cli_mod.shutil, + "get_terminal_size", + lambda *a, **k: __import__("os").terminal_size((80, 10)), + ) + + cli_mod._replay_output_history() + + assert len(cli_mod._OUTPUT_HISTORY) == 200, ( + "_OUTPUT_HISTORY must keep the full buffer after a bounded replay" + ) + + def test_replay_emits_all_when_history_fits(self, monkeypatch): + """When the history is shorter than the visible height, everything is + replayed (no regression for small transcripts).""" + cli_mod._configure_output_history(True, 200) + for i in range(5): + cli_mod._record_output_history(f"line {i}") + + printed = [] + monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed.append(x)) + monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t) + monkeypatch.setattr( + cli_mod.shutil, + "get_terminal_size", + lambda *a, **k: __import__("os").terminal_size((80, 10)), + ) + + cli_mod._replay_output_history() + + assert printed[0].split("\n") == [f"line {i}" for i in range(5)] + + class TestFocusRegainRedraw: """Focus-in (CSI I) routes through the same recovery as Ctrl+L, rate-limited.