fix: implement CSI S/T (scroll up/down) to fix ghost content in screenshots

pyte 0.8.2 does not handle CSI S (SU — Scroll Up) or CSI T (SD —
Scroll Down). When TERM=xterm-256color, tmux sends CSI n S for bulk
scrolling instead of DECSTBM+index. pyte silently ignored these
sequences, leaving old content in the screen buffer — visible as
ghost content in SVG screenshots.

Fix: monkeypatch pyte's CSI dispatch tables to map S→scroll_up and
T→scroll_down, and implement both methods on AltScreen with proper
scroll-region (DECSTBM) support.

Adds 6 tests for SU/SD functionality.
This commit is contained in:
GitHub Copilot
2026-02-06 21:21:57 +00:00
parent c98cee4cd6
commit 207eddc922
3 changed files with 155 additions and 0 deletions
+18
View File
@@ -55,6 +55,24 @@ Frame N+1 (broken): EL2+CUU1 × 6 (clears rows 30→24) + redraw 6 lines ✗
## Fix
### Primary fix: CSI S (Scroll Up) and CSI T (Scroll Down) support
The root cause is that **pyte does not implement `CSI S` (SU — Scroll Up) or `CSI T` (SD — Scroll Down)**. When `TERM=xterm-256color` is set, tmux uses `CSI n S` to scroll content up in the outer terminal instead of the DECSTBM + index approach used with simpler TERM types. Without SU support, pyte silently ignores these scroll commands, leaving old content in place.
The fix monkeypatches pyte's `ByteStream.csi` and `Stream.csi` dispatch tables to map `"S"``scroll_up` and `"T"``scroll_down`, and adds the corresponding methods to `AltScreen`.
```python
# In alt_screen.py — patch pyte's CSI dispatch
pyte.ByteStream.csi["S"] = "scroll_up"
pyte.ByteStream.csi["T"] = "scroll_down"
# AltScreen implements scroll_up() and scroll_down()
# which shift buffer lines within the scroll region,
# matching real terminal behaviour.
```
### Secondary fix: expand_clear_sequences (best-effort)
`AltScreen.expand_clear_sequences()` in `alt_screen.py` pre-processes incoming terminal data before it reaches pyte. It detects runs of 3+ `EL2+CUU1` pairs and, if the run doesn't reach row 0, extends it with additional pairs so the erase covers all lines from the cursor position up to the top of the screen.
```python
+44
View File
@@ -16,12 +16,24 @@ import re
from typing import TYPE_CHECKING, Any
import pyte
from pyte.screens import Margins
# Pattern to match a run of 3+ (EL2 + CUU1) pairs used by Ink/React CLI
# to erase the previous frame before drawing the next one.
_INK_CLEAR_PATTERN = re.compile(rb"(\x1b\[2K\x1b\[1A){3,}")
_EL2_CUU1 = b"\x1b[2K\x1b[1A"
# Patch pyte's CSI dispatch table to handle SU (Scroll Up, CSI S) and
# SD (Scroll Down, CSI T). Without this, tmux output using xterm-256color
# sends CSI S for scrolling which pyte silently ignores, causing ghost
# content to remain on screen.
pyte.ByteStream.csi["S"] = "scroll_up"
pyte.ByteStream.csi["T"] = "scroll_down"
pyte.Stream.csi["S"] = "scroll_up"
pyte.Stream.csi["T"] = "scroll_down"
# Update the events frozenset so pyte recognises these as valid events.
pyte.Stream.events = pyte.Stream.events | frozenset(["scroll_up", "scroll_down"])
if TYPE_CHECKING:
from pyte.screens import Char
@@ -132,6 +144,38 @@ class AltScreen(pyte.Screen):
super().resize(lines, columns)
def scroll_up(self, count: int = 1) -> None:
"""Scroll the screen up by *count* lines within the scroll region.
Lines scrolled off the top are lost; blank lines are added at the
bottom. The cursor position is not changed.
Implements CSI n S (SU — Scroll Up), which pyte does not handle
natively. tmux sends this when TERM supports the ``indn``
capability (e.g. xterm-256color).
"""
top, bottom = self.margins or Margins(0, self.lines - 1)
self.dirty.update(range(self.lines))
for _ in range(min(count, bottom - top + 1)):
for y in range(top, bottom):
self.buffer[y] = self.buffer[y + 1]
self.buffer.pop(bottom, None)
def scroll_down(self, count: int = 1) -> None:
"""Scroll the screen down by *count* lines within the scroll region.
Lines scrolled off the bottom are lost; blank lines are added at
the top. The cursor position is not changed.
Implements CSI n T (SD — Scroll Down).
"""
top, bottom = self.margins or Margins(0, self.lines - 1)
self.dirty.update(range(self.lines))
for _ in range(min(count, bottom - top + 1)):
for y in range(bottom, top, -1):
self.buffer[y] = self.buffer[y - 1]
self.buffer.pop(top, None)
def expand_clear_sequences(self, data: bytes) -> bytes:
"""Expand partial line-by-line clears to cover the full screen.
+93
View File
@@ -206,3 +206,96 @@ class TestExpandClearSequences:
result = screen.expand_clear_sequences(data)
assert result.startswith(b"before")
assert result.endswith(b"after")
class TestScrollUpDown:
"""Tests for CSI S (SU) and CSI T (SD) support."""
def test_scroll_up_basic(self):
"""CSI S scrolls content up, adding blank lines at bottom."""
screen = AltScreen(40, 10)
stream = pyte.ByteStream(screen)
for i in range(10):
stream.feed(f"Line {i}\r\n".encode())
# After writing, Line 0 already scrolled off; rows 0-8 have Lines 1-9
# Scroll up 3 more lines
stream.feed(b"\x1b[3S")
assert "Line 4" in screen.display[0]
assert "Line 9" in screen.display[5]
assert screen.display[6].strip() == ""
def test_scroll_up_default_one(self):
"""CSI S with no parameter defaults to 1 line."""
screen = AltScreen(40, 5)
stream = pyte.ByteStream(screen)
for i in range(5):
stream.feed(f"L{i}\r\n".encode())
stream.feed(b"\x1b[S")
assert "L1" in screen.display[0]
def test_scroll_down_basic(self):
"""CSI T scrolls content down, adding blank lines at top."""
screen = AltScreen(40, 10)
stream = pyte.ByteStream(screen)
for i in range(10):
stream.feed(f"Line {i}\r\n".encode())
stream.feed(b"\x1b[3T")
assert screen.display[0].strip() == ""
assert screen.display[2].strip() == ""
assert "Line 1" in screen.display[3]
def test_scroll_up_with_margins(self):
"""SU respects the scroll region set by DECSTBM."""
screen = AltScreen(40, 10)
stream = pyte.ByteStream(screen)
for i in range(10):
stream.feed(f"Row {i}\r\n".encode())
# After writing, rows 0-8 have Row 1..Row 9
# Set scroll region to rows 3-7 (1-based: 4;8)
stream.feed(b"\x1b[4;8r")
stream.feed(b"\x1b[2S")
# Rows outside the region should be unchanged
assert "Row 1" in screen.display[0]
assert "Row 2" in screen.display[1]
assert "Row 3" in screen.display[2]
# Rows inside the region shifted up by 2
assert "Row 6" in screen.display[3]
def test_scroll_up_clears_ghost_content(self):
"""Simulates tmux sending SU during Ink /clear — ghost content is eliminated."""
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
# Fill screen with "old" content
for i in range(24):
stream.feed(f"Old line {i}\r\n".encode())
non_empty_before = sum(1 for line in screen.display if line.strip())
assert non_empty_before > 15
# Simulate tmux clear: set margins, scroll up, reset margins
stream.feed(b"\x1b[1;23r") # Set scroll region
stream.feed(b"\x1b[20S") # Scroll up 20 lines
stream.feed(b"\x1b[r") # Reset scroll region
non_empty_after = sum(1 for line in screen.display if line.strip())
assert non_empty_after <= 5, f"Expected <= 5 non-empty lines, got {non_empty_after}"
def test_scroll_up_cursor_unchanged(self):
"""SU does not move the cursor position."""
screen = AltScreen(40, 10)
stream = pyte.ByteStream(screen)
stream.feed(b"\x1b[5;10H") # Move cursor to row 5, col 10
saved_y, saved_x = screen.cursor.y, screen.cursor.x
stream.feed(b"\x1b[3S")
assert screen.cursor.y == saved_y
assert screen.cursor.x == saved_x