Fix tmux alt-screen handling
- handle DECSET ?47 as an alternate screen mode so tmux clear redraws don't overlay stale content in screenshots - keep AltScreen mode checks aligned with 47/1047/1048/1049 variants used by full-screen TUIs - document the screenshot debugging workflow in .github/skills/screenshot-debugging/SKILL.md for repeatable escape-sequence analysis
This commit is contained in:
@@ -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.
|
||||||
@@ -19,10 +19,11 @@ import pyte
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyte.screens import Char
|
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 = 1049 << 5
|
||||||
DECALTBUF_1047 = 1047 << 5
|
DECALTBUF_1047 = 1047 << 5
|
||||||
DECALTBUF_1048 = 1048 << 5
|
DECALTBUF_1048 = 1048 << 5
|
||||||
|
DECALTBUF_47 = 47 << 5
|
||||||
|
|
||||||
|
|
||||||
class AltScreen(pyte.Screen):
|
class AltScreen(pyte.Screen):
|
||||||
@@ -77,18 +78,19 @@ class AltScreen(pyte.Screen):
|
|||||||
self.dirty.update(range(self.lines))
|
self.dirty.update(range(self.lines))
|
||||||
|
|
||||||
def _is_alt_buffer_mode(self, modes: tuple[int, ...]) -> bool:
|
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:
|
def _has_alt_buffer_enabled(self) -> bool:
|
||||||
return (
|
return (
|
||||||
DECALTBUF in self.mode
|
DECALTBUF in self.mode
|
||||||
or DECALTBUF_1047 in self.mode
|
or DECALTBUF_1047 in self.mode
|
||||||
or DECALTBUF_1048 in self.mode
|
or DECALTBUF_1048 in self.mode
|
||||||
|
or DECALTBUF_47 in self.mode
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_mode(self, *modes: int, **kwargs: Any) -> None:
|
def set_mode(self, *modes: int, **kwargs: Any) -> None:
|
||||||
"""Set (enable) modes, with special handling for alternate screen buffer."""
|
"""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():
|
if kwargs.get("private") and self._is_alt_buffer_mode(modes) and not self._has_alt_buffer_enabled():
|
||||||
# Save main screen before switching
|
# Save main screen before switching
|
||||||
self._save_main_screen()
|
self._save_main_screen()
|
||||||
@@ -101,7 +103,7 @@ class AltScreen(pyte.Screen):
|
|||||||
|
|
||||||
def reset_mode(self, *modes: int, **kwargs: Any) -> None:
|
def reset_mode(self, *modes: int, **kwargs: Any) -> None:
|
||||||
"""Reset (disable) modes, with special handling for alternate screen buffer."""
|
"""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():
|
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
|
# Will be removed by parent, restore main screen after
|
||||||
super().reset_mode(*modes, **kwargs)
|
super().reset_mode(*modes, **kwargs)
|
||||||
|
|||||||
Reference in New Issue
Block a user