From 207eddc922202f9c01771d6a0015a7e4cb4dc876 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 6 Feb 2026 21:21:57 +0000 Subject: [PATCH] fix: implement CSI S/T (scroll up/down) to fix ghost content in screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ink-clear-fix.md | 18 ++++++++ src/webterm/alt_screen.py | 44 ++++++++++++++++++ tests/test_alt_screen.py | 93 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/docs/ink-clear-fix.md b/docs/ink-clear-fix.md index a9df95a..34cde5e 100644 --- a/docs/ink-clear-fix.md +++ b/docs/ink-clear-fix.md @@ -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 diff --git a/src/webterm/alt_screen.py b/src/webterm/alt_screen.py index 63fec61..9c06bd1 100644 --- a/src/webterm/alt_screen.py +++ b/src/webterm/alt_screen.py @@ -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. diff --git a/tests/test_alt_screen.py b/tests/test_alt_screen.py index 6a26e51..4d71c6c 100644 --- a/tests/test_alt_screen.py +++ b/tests/test_alt_screen.py @@ -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