docs: consolidate pyte patches documentation

Rename ink-clear-fix.md → pyte-patches.md and rewrite as a
comprehensive reference for all pyte monkeypatches:
- Patch 1: CSI S/T (SU/SD scroll) — primary ghost content fix
- Patch 2: Alternate screen buffer (DECSET 1049)
- Patch 3: Ink partial clear expansion (secondary defense)

Update README.md Known Issues to reflect the actual fix approach.
Update ARCHITECTURE.md to document alt_screen.py and pyte patching.
This commit is contained in:
GitHub Copilot
2026-02-06 21:24:52 +00:00
parent d538cae2fa
commit 10ffb9d8cc
4 changed files with 98 additions and 132 deletions
+2 -3
View File
@@ -34,8 +34,7 @@ Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to
## Known Issues
- `pyte` (the library used to capture the underlying terminal state for screenshots) is buggier than a bait store and some partial screen clearing ANSI sequences don't work, resulting in occasionally mis-rendered screenshots. And yet, it is better than most other alternatives, so I'm waiting for `libghostty-vt` to be finished to port this whole thing to Go (or even plain C) and have full fidelity.
- CLI frameworks like [Ink](https://github.com/vadimdemedes/ink) (used by GitHub Copilot CLI) clear their output using line-by-line erase sequences (`EL2+CUU1`) rather than full-screen erase (`ED2`). When `/clear` resets the framework's line counter, partial clears can leave ghost content in pyte's screen buffer. This is mitigated by `AltScreen.expand_clear_sequences()` — see [docs/ink-clear-fix.md](docs/ink-clear-fix.md) for details.
- `pyte` (the library used to capture the underlying terminal state for screenshots) does not implement some standard escape sequences, resulting in occasionally mis-rendered screenshots. We monkeypatch pyte at runtime to add missing support (CSI S/T scroll, alternate screen buffers, etc.) — see [docs/pyte-patches.md](docs/pyte-patches.md) for details. I'm waiting for `libghostty-vt` to be finished to port this whole thing to Go (or even plain C) and have full fidelity.
## Installation
@@ -268,7 +267,7 @@ make bundle-watch
- WebSocket protocol (browser ↔ server) is JSON: `["stdin", data]`, `["resize", {"width": w, "height": h}]`, `["ping", data]`.
- Frontend source is in `src/webterm/static/js/terminal.ts`.
- Screenshots use [pyte](https://github.com/selectel/pyte) for ANSI interpretation and custom SVG rendering. `AltScreen` adds alternate screen buffer support and [Ink partial clear expansion](docs/ink-clear-fix.md).
- Screenshots use [pyte](https://github.com/selectel/pyte) for ANSI interpretation and custom SVG rendering. `AltScreen` adds alternate screen buffer support, [CSI S/T scroll handling, and Ink partial clear expansion](docs/pyte-patches.md).
- CPU stats are read directly from Docker socket using asyncio (no additional dependencies).
## Requirements
+5 -1
View File
@@ -52,7 +52,8 @@ Manages the mapping between route keys and sessions:
Manages a single terminal session:
- **PTY management**: Fork/exec with pseudo-terminal
- **pyte emulator**: Interprets ANSI escape sequences for screen state
- **pyte emulator**: Uses `AltScreen` (patched pyte) for ANSI interpretation
- **Data pipeline**: C1 normalization → `expand_clear_sequences()``stream.feed()`
- **Replay buffer**: 64KB ring buffer for reconnection support
- **Resize handling**: Propagates window size changes to PTY
@@ -218,6 +219,8 @@ pyte provides a pure-Python terminal emulator that tracks screen state character
- Dirty tracking for efficient cache invalidation
- Full ANSI/VT100 escape sequence support
pyte 0.8.2 has gaps (no alternate screen buffer, no CSI S/T scroll), so `AltScreen` in `alt_screen.py` monkeypatches pyte at import time to fill them — see [pyte-patches.md](pyte-patches.md) for details.
### Why session persistence?
Unlike traditional web terminals, sessions survive page refreshes:
@@ -230,6 +233,7 @@ Unlike traditional web terminals, sessions survive page refreshes:
```
src/webterm/
├── alt_screen.py # pyte Screen subclass with alt buffer + SU/SD patches
├── cli.py # Click CLI entry point
├── config.py # Configuration parsing (YAML manifests)
├── local_server.py # Main HTTP/WebSocket server
-128
View File
@@ -1,128 +0,0 @@
# Ink Partial Clear Fix
## Problem
When CLI applications built with [Ink](https://github.com/vadimdemedes/ink) (React for terminals) — such as GitHub Copilot CLI — execute a `/clear` command, the resulting screenshot shows **ghost content**: old conversation lines persist above the fresh prompt.
The real terminal displays correctly, but the pyte-based screen buffer used for SVG screenshot generation retains the stale content.
### Example
Before `/clear`, the screen has 30 lines of conversation. After `/clear`, the user sees only a fresh 6-line prompt, but the screenshot shows:
```
Row 0: [old prompt header] ← ghost content
Row 1: [────────────────]
Row 2: [ hello]
...
Row 20: [● old response text] ← ghost content
Row 21: [/workspace[main]] ← fresh prompt (duplicated)
Row 22: [────────────────]
Row 23: [ Type @ to mention files]
Row 24: [────────────────]
Row 25: [shift+tab cycle mode]
```
## Root Cause
Ink uses a **line-by-line erase** pattern to clear its previous frame before drawing the next one:
```
ESC[2K (EL2 — erase entire line)
ESC[1A (CUU1 — cursor up one row)
```
This pair is repeated once per line of the **previous** frame. Ink tracks how many lines it rendered and erases exactly that many before redrawing.
When `/clear` is issued:
1. Ink resets its internal "previous output height" counter to 0.
2. On the next render cycle, Ink erases **0 old lines** (counter is 0), then draws the fresh prompt (~6 lines).
3. The subsequent render erases 6 lines (the prompt it just drew), redraws — correct from now on.
In a real terminal, the old content has already **scrolled into the scrollback buffer** and is invisible. But pyte's fixed-size `Screen` keeps all content in the visible buffer. The 6-line erase only clears rows at the bottom, leaving rows 024 with orphaned old content.
### Byte-level example
Cursor at row 30 after rendering a full conversation:
```
Frame N (normal): EL2+CUU1 × 28 (clears rows 30→2) + redraw 28 lines ✓
/clear resets counter
Frame N+1 (broken): EL2+CUU1 × 6 (clears rows 30→24) + redraw 6 lines ✗
Rows 023 still contain old content!
```
## 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
# Before fix: 6 pairs clear rows 30→24, leaving 023 dirty
data = b"\x1b[2K\x1b[1A" * 6
# After expand_clear_sequences(): 30 pairs clear rows 30→0
data = screen.expand_clear_sequences(data)
# Now contains 30 pairs of EL2+CUU1
```
Both `DockerExecSession._update_screen()` and `TerminalSession._update_screen()` call this method after C1 normalization and before feeding data to `pyte.ByteStream`.
### Why this is safe
- The fix only triggers on runs of **3 or more** `EL2+CUU1` pairs (normal editing uses 12 at most).
- It only **adds** additional erase operations for lines that would already be empty in a real terminal (they scrolled into scrollback).
- The extra erases are no-ops if the lines are already blank.
- Short runs (< 3 pairs) and runs that already reach row 0 are left unchanged.
## Reproducing
```python
from webterm.alt_screen import AltScreen
import pyte
screen = AltScreen(132, 45)
stream = pyte.ByteStream(screen)
# Draw 27 lines of content
for i in range(27):
stream.feed(f"Content line {i}\r\n".encode())
# Ink /clear: only erases 6 lines
partial_clear = b"\x1b[2K\x1b[1A" * 6 + b"\x1b[2K\x1b[G"
# Without fix: old content remains on rows 020
# With fix: all rows are cleared
expanded = screen.expand_clear_sequences(partial_clear)
stream.feed(expanded)
# Draw fresh prompt
stream.feed(b"Fresh prompt\r\n")
non_empty = [line for line in screen.display if line.strip()]
assert len(non_empty) == 1 # Only "Fresh prompt"
```
## Related
- `WEBTERM_SCREENSHOT_FORCE_REDRAW` env var — sends SIGWINCH to force app redraw before screenshots, but doesn't fix this issue since Ink still only erases its tracked line count.
- `docs/tmux-da-response-filtering.md` — another terminal compatibility fix.
- pyte's known limitations with partial screen clearing are noted in `README.md`.
+91
View File
@@ -0,0 +1,91 @@
# pyte Patches
webterm uses [pyte](https://github.com/selectel/pyte) (v0.8.2) as a headless terminal emulator to capture screen state for SVG screenshots. pyte doesn't implement several standard escape sequences, so `AltScreen` in `alt_screen.py` monkeypatches pyte at import time and provides additional methods.
## Patch 1: CSI S / CSI T — Scroll Up & Down (SU/SD)
**Status:** Primary fix for ghost content in screenshots.
### Problem
When `TERM=xterm-256color`, tmux uses `CSI n S` (Scroll Up) to shift content upward — for example, when Ink-based CLI apps (GitHub Copilot CLI) issue `/clear`. pyte does not implement CSI S or CSI T, silently ignoring the sequences. Old content remains in the screen buffer, appearing as ghost lines in SVG screenshots.
Real terminals (Ghostty, iTerm2, etc.) handle CSI S/T correctly, so the issue only manifests in pyte-rendered screenshots.
### Root cause
pyte's CSI dispatch table has no entry for `"S"` (SU — Scroll Up) or `"T"` (SD — Scroll Down). The `xterm-256color` terminfo entry includes `indn=\E[%p1%dS` and `rin=\E[%p1%dT`, so tmux sends these when the outer terminal advertises support. With simpler TERM values (e.g. `xterm-color`), tmux falls back to DECSTBM + index, which pyte handles correctly.
### Fix
At module load time, `alt_screen.py` patches pyte's dispatch tables and event set:
```python
pyte.ByteStream.csi["S"] = "scroll_up"
pyte.ByteStream.csi["T"] = "scroll_down"
pyte.Stream.csi["S"] = "scroll_up"
pyte.Stream.csi["T"] = "scroll_down"
pyte.Stream.events = pyte.Stream.events | frozenset(["scroll_up", "scroll_down"])
```
`AltScreen` implements `scroll_up(count)` and `scroll_down(count)`, which shift buffer lines within the current scroll region (respecting DECSTBM margins) and blank the vacated rows.
### Verification
```python
from webterm.alt_screen import AltScreen
import pyte
screen = AltScreen(80, 24)
stream = pyte.ByteStream(screen)
# Fill screen with content
for i in range(24):
stream.feed(f"Line {i}\r\n".encode())
# CSI 6 S — scroll up 6 lines (top 6 lines lost, bottom 6 blanked)
stream.feed(b"\x1b[6S")
# Lines 6-23 shifted to rows 0-17, rows 18-23 are blank
assert screen.display[0].strip() == "Line 6"
assert screen.display[17].strip() == "Line 23"
assert screen.display[18].strip() == ""
```
## Patch 2: Alternate Screen Buffer (DECSET/DECRST 1049)
**Status:** Core feature, implemented since v1.0.
### Problem
pyte doesn't implement private mode 1049 (or 47/1047/1048) for alternate screen buffers. Full-screen programs (tmux, vim, less) switch to an alternate buffer on entry and restore the main buffer on exit. Without this, exiting vim inside tmux would leave vim's screen content overlaid on the shell prompt in screenshots.
### Fix
`AltScreen` overrides `set_mode()` and `reset_mode()` to intercept private modes 47/1047/1048/1049. On entry, it deep-copies the current buffer and cursor; on exit, it restores them. The saved buffer is invalidated on resize.
## Patch 3: Ink Partial Clear Expansion (best-effort)
**Status:** Secondary defense; largely superseded by Patch 1.
### Problem
Ink-based CLI frameworks erase their previous output using repeated `EL2 + CUU1` (erase line + cursor up) pairs. When `/clear` resets Ink's internal line counter, the next frame erases fewer lines than needed. In a real terminal the old content has scrolled into the scrollback buffer, but pyte's fixed-size screen retains it.
### Fix
`AltScreen.expand_clear_sequences(data)` pre-processes incoming bytes before feeding to pyte. It detects runs of 3+ `EL2+CUU1` pairs that don't reach row 0 and extends them to cover all lines above the cursor.
Both `TerminalSession._update_screen()` and `DockerExecSession._update_screen()` call this method after C1 normalization and before `stream.feed()`.
### Why this is safe
- Only triggers on runs of **3 or more** `EL2+CUU1` pairs (normal editing uses 12).
- Only adds erase operations for lines that would already be empty in a real terminal.
- Short runs (< 3 pairs) and runs that already reach row 0 are left unchanged.
## Related
- `docs/tmux-da-response-filtering.md` — filtering Device Attributes responses from tmux.
- `docs/ROADMAP.md` — future Go reimplementation would replace pyte entirely.
- `WEBTERM_SCREENSHOT_FORCE_REDRAW` env var — sends SIGWINCH to force app redraw before screenshots.