Skip to content

Add support for the alternate screen buffer - #212

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

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 #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 added 4 commits August 6, 2026 09:15
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.
`_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 closed this by deleting the head repository Aug 14, 2026
ethical-haquer pushed a commit to ethical-haquer/pyte that referenced this pull request Aug 16, 2026
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