Skip to content

Add support for the alternate screen buffer - #212

Open
dwgx wants to merge 6 commits into
selectel:masterfrom
dwgx:altscreen
Open

Add support for the alternate screen buffer#212
dwgx wants to merge 6 commits into
selectel:masterfrom
dwgx:altscreen

Conversation

@dwgx

@dwgx dwgx commented Aug 5, 2026

Copy link
Copy Markdown

Closes #90.

Private modes 47, 1047, 1048 and 1049 were shifted into Screen.mode as
inert bits with no handler, so a full-screen program painted its alternate screen
on top of the primary one and never restored it on exit. Reading such a screen
back gives a merged, impossible state with nothing reporting a problem. As
@shobrook put it on #90: "Most TUIs (e.g. htop) use the alternate screen buffer.
Meaning that pyte cannot emulate most TUIs."

What it does

Entering saves the primary buffer and starts from an empty alternate one; leaving
restores the primary buffer and discards the alternate. Both directions are
idempotent, so a program emitting 1049h twice does not lose the primary screen.
1049 also saves and restores the cursor, since it is defined to combine 1047
with 1048.

Also handled:

  • Screen.alternate_screen exposes the current state.
  • reset() (RIS) returns to the primary buffer and discards both.
  • resize() clips the offscreen buffer, so exiting cannot restore an over-wide
    primary screen.
  • Lines scrolled off the alternate screen no longer enter HistoryScreen
    scrollback — matching why paging a file with less does not leave the file in
    your terminal's history.

How the behaviour was decided

Measured against two independent emulators rather than read off the spec: tmux
3.6b
and GNU screen 4.00.03, feeding identical bytes to a real pane and
diffing the resulting grid cell by cell. Where they agree, that is the target;
they agreed on every case here.

That process settled one point the xterm documentation actively misleads on. The
"without clearing" wording for 47/1047 describes the clearing action, not
buffer lifetime, and taken literally it implies alternate-screen contents survive
a round trip. They do not — in either emulator, re-entering shows an empty
alternate screen regardless of which mode was used. I implemented the literal
reading first and the differential test rejected it. Both emulators also keep a
single buffer-switch flag, so ESC [ ? 4 7 l exits a buffer entered with 1049.
And the cursor is deliberately not homed on entry, which both confirm.

One caveat worth recording for anyone re-running this: GNU screen's altscreen
setting defaults to off (man screen), so without altscreen on in the
screenrc it ignores all four modes and the comparison silently measures a
disabled feature.

Verification

check result
existing test suite 137 passed, 1 xfailed (was 117 + 1 before this change)
differential vs real tmux 3.6b 16/16 grids identical
three-way vs tmux and GNU screen 5/5 agree, 0 undefined
mutation testing 8/8 deliberate breakages caught by the intended test
real less + vim over a PTY 7/7
mypy (strict = true) no new errors
wheel + sdist build ok

The strongest evidence is a control experiment using only behavioural assertions
(no new API), so it runs unchanged against master and against this branch. The
assertion is the one that matters to anyone reading a terminal: after a
full-screen program exits, its body must not still be on screen.

upstream master:  2/4   less leaves 11 lines of body behind; vim leaves its marker
this branch:      4/4

20 tests were added (tests/test_screen.py, tests/test_history.py), including a
parametrised matrix over all nine enter/leave mode combinations. Every one was
mutation-verified — I broke the implementation eight different ways and confirmed
the intended test failed each time.

Unrelated pre-existing note: mypy reports one unused-ignore on
pyte/screens.py:40 on master as well; newer wcwidth ships type information, so
that # type: ignore[import-untyped] is now redundant. Left alone as out of scope.


Prepared with AI assistance and reviewed by me before submitting. The measurement
rig, the two reference emulators and the mutation results are all reproducible; I
am happy to attach the probe scripts or adjust the approach if you would prefer
different semantics for 47/1047.

Private modes 47, 1047, 1048 and 1049 were recorded as inert mode bits, so
a full-screen program painted its alternate screen on top of the primary
one and never restored it on exit. Reading such a screen back gives a
merged, impossible state with nothing reporting a problem -- which is why
`less`, `vim` and `htop` could not be emulated (issue selectel#90).

Entering saves the primary buffer and starts from an empty alternate one;
leaving restores the primary buffer and discards the alternate. Both
directions are idempotent, so a program emitting 1049h twice does not lose
the primary screen. 1049 also saves and restores the cursor, as it is
defined to combine 1047 with 1048. `Screen.alternate_screen` exposes the
state, RIS returns to the primary buffer, and `Screen.resize` clips the
offscreen buffer so exiting cannot restore an over-wide screen.

Lines scrolled off the alternate screen no longer enter HistoryScreen
scrollback, matching why paging a file with `less` does not leave the file
in the terminal's history.

Behaviour was measured against two independent emulators rather than read
off the specification, and they settled one point the xterm documentation
leaves misleading: the "without clearing" wording for 47/1047 describes
clearing, not buffer lifetime. Alternate-screen contents do not survive a
round trip in either tmux 3.6b or GNU screen 4.00.03 (with `altscreen on`),
so there is nothing for per-mode clear flags to distinguish. Both also keep
a single buffer-switch flag, so `ESC [ ? 4 7 l` exits a buffer entered with
1049. The cursor is deliberately not homed on entry, which both confirm.
@dwgx dwgx mentioned this pull request Aug 5, 2026
@dwgx

dwgx commented Aug 5, 2026

Copy link
Copy Markdown
Author

One correction to my own claim above, before anyone spends review time on it.

I said the behaviour was measured against two independent emulators. That is true
for 47, 1047 and 1049, but not for 1048 — and I should have separated
those. Having now probed it specifically, neither reference implements 1048 at
all:

payload: ESC[2;5H  ESC[?1048h  ESC[5;1H "junk"  ESC[?1048l  "X"
         (park cursor, save, move away and write, restore, write X)

tmux 3.6b        : ['', '', '', '', 'junkX']     <- cursor NOT restored
GNU screen 4.0.3 : ['', '', '', '', 'junkX']     <- cursor NOT restored

control, same movement via DECSC/DECRC (ESC 7 / ESC 8):
tmux 3.6b        : ['', '    X', '', '', 'junk'] <- restored, as expected
GNU screen 4.0.3 : ['', '    X', '', '', 'junk']

The DECSC/DECRC control run matters: it shows the rig can detect a cursor restore,
so the 1048 result is a real absence rather than a broken probe.

So 1048 in this PR rests on the xterm documentation alone ("Save cursor as in
DECSC" / "Restore cursor as in DECRC"), not on measurement. I kept it because it
is a two-line alias for the existing save_cursor/restore_cursor, it makes the
documented 1049 = 1047 + 1048 relationship explicit in the code, and it does not
touch the 47/1047/1049 paths. But it is a different evidence class from the
rest of the change, and if you would rather pyte match what tmux and screen
actually do, say so and I will drop ALTBUF_1048 and its handling — the remaining
modes and all their tests are unaffected.

Worth noting the same probe shows 1048h followed by 1049l does not restore the
cursor in either reference either, so they are not simply routing 1048 through a
shared DECSC slot — they ignore it outright.

Neither was reachable by the tests as first written, so both get a regression
test named after the mechanism it locks.

1048/1049 no longer share the `savepoints` stack with DECSC. Full-screen
programs use DECSC *inside* the alternate screen constantly, and an unbalanced
`ESC 7` there left the wrong savepoint on top, so exiting restored the
program's internal cursor instead of the shell's. Measured on tmux 3.6b: park
at row 3 col 7, enter 1049, `ESC [ 1 ; 1 H` then `ESC 7`, exit -- the cursor
returns to row 3 col 7, where this code returned it to row 1 col 1. The saved
cursor now lives in its own slot, and a stray 1049l with nothing saved leaves
the cursor alone rather than falling through to restore_cursor's empty-stack
homing.

Shrinking via resize() now drops rows from the TOP of the offscreen buffer,
matching what the displayed buffer does through delete_lines. Clipping the
bottom instead kept the wrong half, so a program that resized while on the
alternate screen restored the wrong rows on exit.

The existing resize test encoded the bug: its expected value was written by
hand from the broken output. It now derives the expectation by resizing a
screen that never entered the alternate buffer, so the assertion cannot agree
with a defect in the code it checks.
@dwgx

dwgx commented Aug 5, 2026

Copy link
Copy Markdown
Author

Pushed a second commit fixing two defects I found by reviewing my own patch
adversarially rather than waiting for review to find them. Both were invisible to
the tests as originally written.

1049 shared the savepoints stack with DECSC. Full-screen programs use DECSC
inside the alternate screen constantly, so an unbalanced ESC 7 in there left
the wrong savepoint on top and exiting restored the program's internal cursor
instead of the shell's:

payload: ESC[3;7H  ESC[?1049h  ESC[1;1H  ESC 7  ESC[?1049l  "X"

tmux 3.6b   : ['', '', '      X']   <- cursor back at row 3 col 7
before fix  : ['X']                 <- restored the DECSC savepoint instead

The 1048/1049 cursor now lives in its own slot. A stray 1049l with nothing saved
also leaves the cursor alone, instead of falling through to restore_cursor's
empty-stack homing.

resize() clipped the offscreen buffer from the wrong end. Shrinking drops
rows from the top of the displayed buffer (via delete_lines), but I was popping
rows off the bottom of the saved one — so a program that resized while on the
alternate screen restored the wrong half. AAA/BBB/CCC/DDD shrunk to two rows
gave AAA/BBB where resizing without ever entering the alternate screen gives
CCC/DDD.

Worth flagging the reason the second one survived my first pass, since it is the
more general mistake: the existing resize test had its expected value written by
hand from my own broken output
, so it agreed with the defect. It now derives the
expectation by resizing a screen that never entered the alternate buffer — the
assertion can no longer be satisfied by a bug in the code it checks.

Re-verified after the fixes: 140 passed / 1 xfailed, differential vs real tmux
16/16, three-way vs tmux and GNU screen 5/5, real less + vim over a PTY 7/7,
control experiment still 4/4 (master 2/4), mypy --strict no new errors. Mutation
coverage extended to the new code: 11/11 deliberate breakages caught, including
one that reverts 1048/1049 to the shared DECSC stack and one that puts the resize
clipping back on the wrong end.

I also probed four more interactions that turned out clean, listed here so they do
not need re-checking: DECSCNM toggled while on the alternate screen (the restored
buffer's blank cells report the right reverse state), HistoryScreen pagination
inside the alternate screen (no leakage into history), heavy scrolling on the
alternate screen (primary screen intact), and DECSTBM margins set on the alternate
screen (no residue after exit — matches tmux).

A multi-agent review of the previous two commits found these; each is reproduced
in memory and locked by a test named after the mechanism.

A nested smcup no longer clobbers the saved cursor. `_switch_screen_buffer` was
already idempotent but `_save_alt_cursor` was called unconditionally, so a
program emitting `ESC [ ? 1 0 4 9 h` twice lost the shell's cursor and rmcup
restored the program's own. Measured on tmux 3.6b: `ESC[3;7H smcup ESC[5;15H
smcup rmcup` restores the cursor to row 3 col 7, identical to the single-smcup
case. 1048 on its own is an explicit DECSC and still re-saves, including while
the alternate screen is up -- suppressing that too would be an over-correction,
so both directions are pinned.

DECSCNM now reaches both buffers. It is a property of the SCREEN, but pyte
realises it by rewriting each cell, and only the displayed buffer was rewritten.
So toggling reverse video while on the alternate screen restored a primary
screen whose cells contradicted `self.mode`. A new `_all_buffers` helper feeds
both set_mode and reset_mode.

`HistoryScreen.prev_page` is guarded on the alternate screen. index and
reverse_index already were; paging was not, so scrolling back spliced primary
scrollback into the running program's display and pushed its rows into
history.bottom. next_page needs no guard and does not get one: `before_event`
forces the history to the bottom on every non-paging event, switching buffers is
one, so by then `position == size` and `history.bottom` is empty. A guard there
would be unobservable code, and a test asserting on it would pass either way --
which is how it was caught.

The modes.py docstrings for 47 and 1047 claimed neither entering nor leaving
clears a buffer. The implementation discards the alternate buffer on exit for
all three modes, so the docs contradicted the code and would have shipped that
contradiction to the published API reference.
@dwgx

dwgx commented Aug 6, 2026

Copy link
Copy Markdown
Author

Third commit pushed. A multi-agent adversarial review of my own two commits found
four more defects, and it also contradicted a claim I made in the comment above,
which I want to correct explicitly rather than quietly amend.

The claim that was wrong

I listed DECSCNM toggled while on the alternate screen among interactions
"that turned out clean, listed here so they do not need re-checking". It is not
clean, and it was the same defect class as the resize bug I had just fixed:

"ab"  ESC[?1049h  ESC[?5h  ESC[?1049l          (set reverse video on the alt screen)

restored primary row 0 reverse flags : [False, False, False, False]
same payload without the alt screen  : [True,  True,  True,  True]
DECSCNM in screen.mode               : True

DECSCNM is a property of the SCREEN, but pyte realises it by rewriting each cell,
and only the displayed buffer was being rewritten — so the restored primary screen
disagreed with self.mode. Fixed by feeding both buffers through a new
_all_buffers helper. My earlier check had looked at the screen-level flag and
concluded the interaction was handled; it never compared the restored cells.

The other three

A nested smcup clobbered the saved cursor. _switch_screen_buffer was
idempotent but _save_alt_cursor was called unconditionally, so smcup twice lost
the shell's cursor and rmcup restored the program's own. Measured on tmux 3.6b:
ESC[3;7H smcup ESC[5;15H smcup rmcup puts the cursor back at row 3 col 7 —
identical to the single-smcup case. 1048 alone is an explicit DECSC and still
re-saves, including while the alternate screen is up; suppressing that too would
have been an over-correction, so both directions are now pinned.

HistoryScreen.prev_page was unguarded. index/reverse_index got the
not self.alternate_screen check; paging did not, so scrolling back spliced
primary scrollback into the running program's display and pushed its rows into
history.bottom.

next_page deliberately does not get a guard: before_event forces the
history to the bottom on every non-paging event, switching buffers is one, so by
the time the alternate screen is up position == size and history.bottom is
empty — it is already inert. I wrote that guard first, and mutation testing caught
it: removing it left the test green, because the assertion could not distinguish
guarded from inert. Unobservable code plus an assertion that proves nothing, both
removed.

modes.py contradicted the implementation. The 47 and 1047 docstrings said
neither entering nor leaving clears a buffer, while the code discards the alternate
buffer on exit for all three modes — and those docstrings ship to the API
reference via automodule.

A harness defect worth reporting on its own

While re-verifying, the mutation harness itself proved non-deterministic: the same
mutation came back "caught" or "false-green" depending on the run. Cause: Python
validates a .pyc against the source's (mtime in whole seconds, size), and two
mutations of the same line produce files of identical size — so when both writes
land in the same second, the second run silently executes the first mutation's
bytecode. Fixed by sweeping __pycache__ and setting PYTHONDONTWRITEBYTECODE
per run.

That mattered because the "11/11 mutations caught" figure in my first comment was
measured with the broken harness. I re-ran the whole first batch against the
fixed one: still 11/11.
The race happened to fail pessimistic here (it reported
caught mutations as false-greens, not the reverse), but it could equally have
hidden a real one, so the number needed re-establishing rather than assuming.

Current state

existing suite            145 passed, 1 xfailed   (117 + 1 before this PR)
alternate-screen tests    28 collected
mutation testing          11/11 first batch + 5/5 review batch, race-free harness
differential vs tmux      16/16 identical
three-way vs tmux+screen  5/5 agree, 0 undefined
mypy strict               no new errors

The verification table in the PR body is now stale (it says 137 passed / 20 tests);
treat the block above as current. I will not keep editing the body — the commits
and these comments are the record.

dwgx added 3 commits August 6, 2026 11:04
`_alternate_screen` was exactly `_offscreen_buffer is not None` at every reachable
state, so it was a second copy of one fact that could drift out of step. A review
pass flagged it as a style point; it is more than that — two values that must agree
are a defect waiting for the next contributor to set one and not the other.

Equivalence was checked before removing it, not assumed: 2197 three-step sequences
over smcup/rmcup in all three modes, 1048, RIS, DECCOLM, erase and text, plus a
resize after each — 8788 states, zero disagreements.

Net effect is one less attribute, one less assignment in the switch, and a property
that cannot be wrong. Suite unchanged at 145 passed / 1 xfailed, mypy unchanged,
differential against real tmux 19/19 identical, mutation testing 11/11 + 5/5.

One knock-on worth noting for anyone editing the mutation harness: removing that
assignment made `self.dirty.update(range(self.lines))` non-unique in the file, so
the "home cursor on entry" mutation had to anchor on the preceding line as well.
A third review round found that 1048 and 1049 shared `_alt_savepoint`, so a
program issuing `ESC [ ? 1 0 4 8 h` while the alternate screen was up overwrote
the cursor smcup had saved for the shell:

    ESC[3;7H smcup ESC[5;15H  <inner save>  rmcup
      nothing saved inside : cursor (2, 6)   <- the shell's cursor
      ESC 7        inside  : cursor (2, 6)   <- correct
      ESC[?1048h   inside  : cursor (4, 14)  <- the program's cursor

This is the same defect the dedicated slot was introduced to fix for DECSC one
commit earlier — reintroduced by routing 1048 through the same slot when it was
added. 1048 is an independent DECSC that a program may legitimately issue inside
the alternate screen; it needs its own storage.

`_save_alt_cursor`/`_restore_alt_cursor` now take a `cursor_only` flag selecting
between two slots, and set_mode/reset_mode dispatch 1048 and 1049 separately
rather than sharing one branch.

Also closes a blind spot in the new test rather than only adding the case that
failed. The paired sequence (`1048h` then `1048l` inside the alternate screen)
cannot distinguish a suppressed save from a working one — a suppressed save
leaves an empty slot, the restore is inert, and 1049 supplies the same answer
either way. Mutation testing caught that: suppressing 1048 inside the alternate
screen left the test green. The test now moves the cursor between `1048h` and
`1048l`, so the save has to have happened.

146 passed / 1 xfailed, differential against real tmux 19/19 identical, mutation
testing 11/11 + 5/5 with the two slot-sharing reversions both caught, mypy at
its single pre-existing error.
A maintainer-perspective review measured the branch against the rest of the file
and the objections held up:

- `_switch_screen_buffer` was the most prose-heavy method in the branch, and its
  longest paragraph was a 10-line note about xterm's "do not clear" wording that
  argued a point rather than telling a caller anything. Cut to two short
  paragraphs: the cursor is not homed, and all three modes behave identically
  because no reference implementation preserves alternate-screen contents.
- Measurement transcripts ("park at row 3 col 7, enter 1049, ...") removed from
  docstrings. Master pins no tool version in any docstring and neither should
  this.
- The CHANGES entry was the only 7-line bullet in a file whose 90 bullets have a
  median of 2. Now 2 lines.

Two deliberate departures from that review. The tool versions stay in the two
INTERNAL comments that record why a branch is written the way it is — a docstring
is for the caller, a comment is for whoever edits the code next, and deleting the
latter loses the evidence. And `HistoryScreen.prev_page` gained a docstring line
rather than losing one: it is exported through `automodule :members:`, so its new
silent no-op on the alternate screen has to be discoverable from the API
reference, where previously only an internal comment mentioned it.

Also replaced the now-unreachable `saved if saved is not None else
self._make_buffer()` fallback with an assert. Since `alternate_screen` became
derived, the early return guarantees the offscreen buffer exists on that path.

146 passed / 1 xfailed, mypy unchanged, differential against real tmux 19/19,
mutation 11/11 + 5/5.
@dwgx

dwgx commented Aug 6, 2026

Copy link
Copy Markdown
Author

Three more commits. A third review round found one real defect, and it was mine —
introduced by the 1048 support in this same PR.

1048 was clobbering 1049's saved cursor

_save_alt_cursor writes one slot, and I routed both 1048 and 1049 through it. So a
program issuing ESC [ ? 1 0 4 8 h while the alternate screen was up overwrote the
cursor smcup had saved for the shell:

ESC[3;7H  smcup  ESC[5;15H  <inner save>  rmcup

  nothing saved inside : cursor (2, 6)    <- the shell's cursor, correct
  ESC 7        inside  : cursor (2, 6)    <- correct
  ESC[?1048h   inside  : cursor (4, 14)   <- the program's cursor

This is precisely the defect the dedicated slot was added for one commit earlier,
reintroduced when 1048 was given the same storage. 1048 is an independent DECSC
that a program may legitimately issue inside the alternate screen, so it needs its
own slot. It has one now, and the test covers the combination.

cursor_down was missing the same region guard as index and cursor_up

Not fixed in this PR — flagging it because it is the same defect class and a
reviewer will ask. pyte clamps CUD to the DECSTBM bottom margin unconditionally, so
a cursor below the region gets dragged up into it:

10-line screen, ESC[3;6r  ESC[8;1H  ESC[1B   ->  row 6
tmux 3.6b and GNU screen 4.00.03            ->  row 9

index() and cursor_up() have the guard; cursor_down() does not. It belongs in
a separate PR with the other cursor-motion fixes, not bundled here.

Trimmed to this codebase's register

A maintainer-perspective review measured the branch against the rest of the file,
and the objections were fair:

  • _switch_screen_buffer was the most prose-heavy method in the branch, and its
    longest paragraph argued a point about xterm's "do not clear" wording rather
    than telling a caller anything. Cut to two short paragraphs.
  • Measurement transcripts removed from docstrings. Master pins no tool version in
    any docstring.
  • The CHANGES entry was the sole 7-line bullet in a file whose 90 bullets have a
    median of 2. Now 2 lines.

Two deliberate departures. Tool versions stay in the two internal comments that
record why a branch is written the way it is — a docstring is for the caller, a
comment is for whoever edits next, and deleting the latter loses the evidence. And
HistoryScreen.prev_page gained a docstring line rather than losing one: it is
exported through automodule :members:, so its new no-op on the alternate screen
has to be discoverable from the API reference.

Also replaced the now-unreachable saved if saved is not None else self._make_buffer() with an assert, since alternate_screen became derived and
the early return guarantees the offscreen buffer exists on that path.

Two blind spots in my own tests

Both found by mutation testing, not by review, and worth stating because they are
the same shape:

The paired 1048 sequence could not see a suppressed save. 1048h then 1048l
inside the alternate screen: if the save is suppressed the slot is empty, the
restore is inert, and 1049 supplies the same final answer — so the assertion passed
either way. The test now moves the cursor between the two, forcing the save to have
happened.

A row-cap assertion never entered the code it checked. Without a DECSTBM region
the override is not taken at all, so pyte's own clamp did the work and deleting the
override's bound left the test green. Now driven with the cursor outside a region.

Current state

suite                     146 passed, 1 xfailed   (117 + 1 before this PR)
differential vs tmux      19/19 grids identical
mutation testing          11/11 + 5/5, race-free harness
mypy strict               no new errors

Verified after every change, not once at the end.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Alternate screen buffer

1 participant