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:
25
cli.py
25
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user