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
This commit is contained in:
Jackal991
2026-09-23 06:59:06 -07:00
committed by Teknium
parent 1bdc455139
commit e7bcdcc0c9
2 changed files with 103 additions and 1 deletions

25
cli.py
View File

@@ -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:

View File

@@ -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.