diff --git a/.github/skills/screenshot-debugging/SKILL.md b/.github/skills/screenshot-debugging/SKILL.md new file mode 100644 index 0000000..6058043 --- /dev/null +++ b/.github/skills/screenshot-debugging/SKILL.md @@ -0,0 +1,65 @@ +# Screenshot Debugging Skill + +## Purpose +Diagnose terminal screenshot corruption caused by incomplete escape-sequence handling. + +## When to Use +- SVG screenshot shows stale or overlaid content after clear/redraw. +- Behavior differs between live terminal output and screenshot snapshots. +- Issues appear inside tmux/vim/less or other full-screen TUIs. + +## Procedure +1. **Reproduce and capture raw output** + - Capture PTY output around the failing action (e.g., `clear` inside tmux). + - Ensure capture includes the full sequence before and after the command. + +2. **Replay into the emulator** + - Feed captured bytes into the same emulator used for screenshots (pyte + AltScreen). + - Inspect the rendered buffer for stale cells or overlay. + +3. **Scan for unhandled escape modes** + - Look for private modes: `?47`, `?1047`, `?1048`, `?1049`. + - Check erase semantics: `ED` (`J`), `EL` (`K`), `ECH` (`X`). + - Verify C1 controls are normalized to 7-bit ESC equivalents. + +4. **Fix emulator handling** + - Update AltScreen to recognize any missing alternate buffer modes (e.g., `?47`). + - Ensure mode toggles save/restore the main buffer and mark dirty lines. + +5. **Add regression coverage** + - Add a focused test that replays the sequence and asserts the buffer is cleared. + - Include any new mode variants in existing parameterized tests. + +6. **Verify** + - Run `make check`. + - Re-test the real scenario and confirm screenshots match the live terminal. + +## Minimal Capture Snippet (PTY -> pyte) +```python +import os, pty, select, time, pyte +from webterm.alt_screen import AltScreen + +def read_all(fd, timeout=0.5): + out = b"" + end = time.time() + timeout + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.05) + if not r: + continue + try: + data = os.read(fd, 4096) + except OSError: + break + if not data: + break + out += data + return out + +screen = AltScreen(80, 24) +stream = pyte.ByteStream(screen) +stream.feed(raw_bytes) +``` + +## Notes +- tmux often uses `DECSET ?47` (legacy alt buffer) instead of `?1049`. +- Always validate with real output captures, not just synthetic sequences. diff --git a/src/webterm/alt_screen.py b/src/webterm/alt_screen.py index 2269029..655584e 100644 --- a/src/webterm/alt_screen.py +++ b/src/webterm/alt_screen.py @@ -19,10 +19,11 @@ import pyte if TYPE_CHECKING: from pyte.screens import Char -# Private mode alternate screen buffers (1047/1048/1049) - shifted by 5 per pyte's convention +# Private mode alternate screen buffers (1047/1048/1049/47) - shifted by 5 per pyte's convention DECALTBUF = 1049 << 5 DECALTBUF_1047 = 1047 << 5 DECALTBUF_1048 = 1048 << 5 +DECALTBUF_47 = 47 << 5 class AltScreen(pyte.Screen): @@ -77,18 +78,19 @@ class AltScreen(pyte.Screen): self.dirty.update(range(self.lines)) def _is_alt_buffer_mode(self, modes: tuple[int, ...]) -> bool: - return 1047 in modes or 1048 in modes or 1049 in modes + return 47 in modes or 1047 in modes or 1048 in modes or 1049 in modes def _has_alt_buffer_enabled(self) -> bool: return ( DECALTBUF in self.mode or DECALTBUF_1047 in self.mode or DECALTBUF_1048 in self.mode + or DECALTBUF_47 in self.mode ) def set_mode(self, *modes: int, **kwargs: Any) -> None: """Set (enable) modes, with special handling for alternate screen buffer.""" - # Check if we're entering alternate screen mode (private mode 1047/1048/1049) + # Check if we're entering alternate screen mode (private mode 47/1047/1048/1049) if kwargs.get("private") and self._is_alt_buffer_mode(modes) and not self._has_alt_buffer_enabled(): # Save main screen before switching self._save_main_screen() @@ -101,7 +103,7 @@ class AltScreen(pyte.Screen): def reset_mode(self, *modes: int, **kwargs: Any) -> None: """Reset (disable) modes, with special handling for alternate screen buffer.""" - # Check if we're leaving alternate screen mode (private mode 1047/1048/1049) + # Check if we're leaving alternate screen mode (private mode 47/1047/1048/1049) if kwargs.get("private") and self._is_alt_buffer_mode(modes) and self._has_alt_buffer_enabled(): # Will be removed by parent, restore main screen after super().reset_mode(*modes, **kwargs)