Skip to content

Fix the alt-screen rig, six perception bugs, and two dependency timebombs - #8

Merged
dwgx merged 28 commits into
mainfrom
fix/altscreen-rig-and-1048
Aug 6, 2026
Merged

Fix the alt-screen rig, six perception bugs, and two dependency timebombs#8
dwgx merged 28 commits into
mainfrom
fix/altscreen-rig-and-1048

Conversation

@dwgx

@dwgx dwgx commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Started as three small fixes found while contributing the alternate screen buffer
upstream to pyte (selectel/pyte#212,
closing a 9-year-old help-wanted issue). Two rounds of adversarial review turned
it into sixteen commits, because building a correct implementation upstream forced
measurements this repo had never made — and several of them were about this repo.

tests/run_all.py is 43/43 on macOS, the first full green here. It was 39/43.

Two dependency timebombs

Both are the same shape — subclass and base class doing the same work — and both
arrive as pip install --upgrade, not as a code change. pyte>=0.8.1 is an open
range in requirements.txt and pyproject.toml.

The alternate screen. pyte has never implemented it (issue #90, open since
2017), which is why _Screen does. The day #212 ships in a release, both layers
switch and the primary screen restores blank. Measured against a patched pyte:
a smcup PAGER rmcup round trip gave ['', '', ''].

DCH over a wide glyph. delete_characters widens the count because pyte
deletes cells without regard to width. Against a pyte that does the same, 中x +
CR + DCH went from "x" to "" — silently eating a character.

Fixed by asking the installed pyte what it can do (_PYTE_HAS_ALT via hasattr,
_PYTE_DCH_HANDLES_WIDE via a one-shot behavioural probe, since there is no
attribute for that one) rather than pinning pyte<0.8.3. A cap would keep users
off the upstream fix forever and need revising every release.

Both verified in both directions, under stock 0.8.2 and under a patched
checkout — a one-sided test cannot tell "correct" from "the branch that happens to
run here". Mutation testing needed both ends too: hardcoding the DCH probe False is
only observable under a pyte that widens DCH, hardcoding it True only under one
that does not.

Perception bugs that were silently wrong

The worst: an unclamped cursor restore blinded the screen. _leave_alt
assigned the saved row and column raw, so if the terminal shrank while a
full-screen program owned the alternate screen, the cursor came back outside the
buffer and every subsequent write landed on a row display never renders. Cursor
at row 6 of 8, smcup, resize to 3 rows, rmcup → cursor (6,3) on a 3-row screen,
and AFTER written next was invisible. The agent reads a screen that has
stopped updating, with nothing reporting a problem.

DL did not blank the rows it vacated. pyte copies
buffer[y] = buffer.pop(y + count) only if y + count in self.buffer, so a source
row that was never written left the destination holding its old contents.
Measured against tmux 3.6b and GNU screen 4.00.03, which agree on all five
locked cases: Q + CR + ESC[1M left Q on screen, and A/B/C + ESC[2M
deleted a single row, giving ['C','B'] where both references give ['C']. The
earlier IL-side fix masked this — its recorded repro happens to materialise the
rows first, so it passed while the minimal case stayed broken.

The SGR pen was not restored. 1049 is "save cursor as in DECSC", and DECSC
saves the graphic rendition, so a TUI exiting with reverse video still set tinted
every character the shell wrote afterwards. Also: RIS did not leave the alternate
screen, and resize(0, 0) wiped the saved primary screen (pyte treats 0 as
"unchanged"; this tested is None).

The test rig was lying in three places

tests/_diff_two_refs.py drove GNU screen without altscreen on, which defaults
off.
Both alt-screen cases had been comparing against a reference with the
feature disabled and were reported as reference-vs-reference disagreements for a
rig reason. The probe went 35 → 37 arbitrated cases, and this repo's
alternate-screen implementation is verified for the first time rather than merely
unrefuted.

Five driven fixture apps opened with import sys, msvcrt. On POSIX they died
before drawing anything, so three drive probes saw a traceback instead of a menu.
With examples/drive_vim.py unable to import from a checkout, that was a four-entry
noise floor in which a genuine regression was indistinguishable from the platform
gap. tests/_kbd.py now provides a cross-platform getwch() — raw mode entered
once, not per keystroke, or an ESC [ A gets split across three separate
raw-mode entries.

_diff_fuzz_tmux has a load-dependent flake, failing in a full-suite run and
passing standalone with identical argv. rerun=True is safe because the seed is
fixed: a real divergence fails twice, a slow tmux capture fails once.

Method notes, which cost more than the fixes

Three separate times a differential failure turned out to be the rig: less -X
disables the alternate screen (the feature under test), GNU screen's altscreen
defaults off, and a git stash push with nothing staged meant a control experiment
ran the same code twice. "Suspect the rig first" has a measured hit rate here.

The mutation harness itself was non-deterministic. Python validates a .pyc
against (mtime in whole seconds, size), and two mutations of one line produce
identical-size files, so same-second writes ran the previous mutation's bytecode.
The "11/11 caught" figure published upstream was measured on that harness; it
re-established at 11/11 after the fix, but it had to be re-established.

Twice a test was written with its expected value copied from my own broken
output
, so it locked the bug in. Both now derive expectations from a path that
cannot contain the defect — resizing a screen that never entered the alternate
buffer. And one batch of locks was appended after the module's sys.exit(0) and
never ran; mutation testing reporting 0/4 caught is the only reason that was
noticed instead of shipping as a green-looking no-op.

Also here

Mode 1048 is supported with its weaker evidence level stated in the code —
xterm defines it, neither reference emulator implements it, and an unpaired
1048l is inert rather than teleporting the cursor the way pyte's empty-stack
restore_cursor would. alt_screen reached the surfaces an agent actually
reads (ScreenModel, Snapshot, the to_text() header, JSON hints, every
drive-tui reply), having been available only by reaching through to the pyte
object. DECCOLM — the one mode whose handler does real buffer work — was probed
against the alternate screen in six shapes, found already correct, and the two
touching the saved primary screen are pinned. Count drift in HANDOFF.md and
run_all.py corrected, and HANDOFF.md §10i records all of it.

Tested

43/43 run_all.py (up from 39/43), 19 deterministic gates, curated differential
35/35, generative fuzz clean on two seeds, three-way tmux+screen probe green, a
real less driven over a real PTY 10/10, drive_vim 6/6 against a real editor,
mutation-verified throughout with the harness race fixed, vendored copy
byte-identical, mypy at baseline. The fidelity suite is green under three pyte
builds: stock 0.8.2, the pending #212 patch, and a checkout carrying six ported
screen-model fixes.

dwgx added 13 commits August 6, 2026 06:54
… off

The two-reference probe drove GNU screen without `altscreen on`, and that
setting defaults to off (man screen: "Initial setting is `off'"). So screen
ignored 47/1047/1049 entirely and both alt-screen cases were comparing our
model against a reference with the feature DISABLED — each was reported as a
reference-vs-reference disagreement (UNDEFINED) for a rig reason rather than a
semantic one.

With the setting on, both references agree and we match them: the probe goes
from 35 to 37 arbitrated cases, the two alt-screen cases now genuinely PASS,
and our alternate-screen implementation is verified for the first time rather
than merely unrefuted.

This is the probe's own "suspect the rig first" lesson landing on the probe.
… labels

HANDOFF said the suite was 39 entries; build_suite() returns 43, and
registration is unconditional so the number does not vary by host. Two older
mentions said 37. The count was written from memory on the same day the code
was read.

run_all.py labelled the fx contract test "28 fx x sizes" while the catalog has
30. The test enumerates all_effects(), so the number in the label was never
load-bearing — it is now phrased without one, since a hard-coded count there is
drift waiting to happen and test_doc_counts does not scan test labels.
pyte.Screen.resize only touches self.buffer, which during alternate-screen mode
is the ALTERNATE buffer — the saved primary screen is invisible to it. So a
terminal resized while a full-screen program was running restored a primary
screen of the old shape on exit.

Two observable consequences. The wrong ROWS came back: pyte drops rows from the
top when shrinking, while an untouched save keeps its original numbering, so
AAA/BBB/CCC/DDD shrunk to two rows restored AAA/BBB where resizing without the
alternate screen gives CCC/DDD. And over-wide cells survived past the new right
margin — hidden by `display`, which only renders range(columns), but a later
grow-back would surface content that should have been clipped.

The scenario is ordinary, not exotic: the user drags the terminal window while
vim/less/htop is up, or drive-tui's own resize action fires mid-drive.

Found by re-auditing this class against the same defect in the upstream patch
for pyte issue #90 — the offscreen buffer there had the identical hole. The
1049 cursor slot was checked at the same time and is structurally fine here,
because it is a dedicated tuple rather than pyte's shared DECSC savepoint stack.

Verified under the smartcli_core exception: real run path (a real `less` driven
over a real PTY, resized while it owned the alternate screen, restored at the
new shape with no pager residue and no over-wide cells, zero leaked sessions);
adversarial pass (3 mutations, each caught by the new lock); no regressions (19
deterministic gates green, vendored copy re-synced, no new mypy errors). The
locks in test_terminal_fidelity.py derive their expected values from resizing a
screen that never entered the alternate buffer, so they cannot agree with a
defect in the clipping code they check.
An adversarial review of the previous resize fix found that it protected the
saved BUFFER and nothing else attached to the saved CURSOR. Four defects, all
reproduced, all locked.

The worst one silently blinded perception. `_leave_alt` assigned the saved row
and column raw, so if the screen had shrunk while the program owned the
alternate screen the cursor came back outside the buffer -- and every write
after that landed on a row `display` never renders. The agent read a screen that
had stopped updating, with nothing reporting a problem. Concretely: cursor at row
6 of 8, smcup, resize to 3 rows, rmcup -> cursor (6,3) on a 3-row screen, and
"AFTER" written next was invisible. The restore now clamps.

The pen was not restored. 1049 is defined as "save cursor as in DECSC", and DECSC
saves the graphic rendition too, so `_saved_cursor` now carries `attrs`. Without
it a TUI that exited with reverse video still set tinted every character the
shell wrote afterwards -- measured: all five cells of the next word came back
`reverse=True`.

RIS did not leave the alternate screen. `pyte.Screen.reset` clears the buffer but
knows nothing about the state kept here, so after `ESC c` the flag stayed set:
the next program's smcup became a silent no-op, and a later rmcup resurrected the
pre-RIS screen. A `reset()` override discards all three fields.

`resize(0, 0)` wiped the saved primary screen, because the override tested `is
None` where pyte's own resize treats 0 as "unchanged" (`lines = lines or
self.lines`). Now it agrees with the base class.

Verified under the smartcli_core exception. Real run path: a real `less` driven
over a real PTY, resized while it owned the alternate screen, and after exit the
cursor is in bounds, the pager leaves no residue, the shell is still VISIBLE, and
its output is untinted -- 10/10 checks, zero leaked sessions. Adversarial pass: 4
mutations, each caught. No regressions: 19 deterministic gates green, vendored
copy re-synced, no new mypy errors.

One process note. The first attempt at these locks was appended after the
module's `sys.exit(0)`, so it never ran -- and mutation testing reported 0/4
caught, which is the only reason that was noticed rather than shipped as a
green-looking no-op.
pyte has never implemented the alternate screen buffer (issue #90, open since
2017), which is why `_Screen` implements it here. An upstream patch is now
pending, and `pyte>=0.8.1` is an open range in both requirements.txt and
pyproject.toml — so the day that patch ships in a release, every installation
picks it up automatically and BOTH layers switch buffers. Measured against a
patched pyte: a `smcup PAGER rmcup` round trip restored `['', '', '']` where the
primary screen should have been. A dependency upgrade would silently reintroduce
the exact bug this class was written to prevent, on a released package.

Fixed by capability detection rather than a version cap. `_PYTE_HAS_ALT` is
`hasattr(pyte.Screen, "alternate_screen")`, evaluated once; when true the base
class owns the switching and `alt_screen` reads through to it. A `pyte<0.8.3`
pin would have kept users off the upstream fix forever and needed revising every
release; the check is correct before and after, and needs no maintenance.

Verified in BOTH directions, which is the only way this could be established:
against the installed pyte 0.8.2 (19 deterministic gates green, real-PTY `less`
drive 10/10, no new mypy errors) and against a patched pyte checkout, where the
whole fidelity suite is green with detection on and fails 6 checks with the flag
forced off — so the mutation IS observable, just not on a stock install.

The new test exercises the not-taken branch through a real `_Screen` subclass
that only overrides the flag. A fabricated base class was tried first and broke
zero-argument `super()`; that was a fair signal it sat too far from the real path
to prove anything, and HARD RULE 5 says as much about harnesses that patch away
the gap they are meant to test.
1048 saves and restores the cursor as DECSC/DECRC do, without switching buffers.
It is the other half of 1049, which xterm documents as 1047 combined with 1048.

The evidence for this one is WEAKER than for anything else in this class, and the
code says so rather than letting a reader assume otherwise: xterm specifies 1048,
but NEITHER reference emulator implements it. Measured — a 1048h/1048l pair does
not restore the cursor in tmux 3.6b or GNU screen 4.00.03, while the same movement
through DECSC/DECRC does in both, so the probe can detect a restore and the
absence is real rather than a rig artifact. Every full-screen program in practice
emits 1049.

Supported anyway because this layer's job is to perceive what a program SENT, and
a program sending 1048 means DECSC; matching the references by ignoring it would
mean silently dropping a documented sequence. Kept out of _ALT_MODES so it can
never affect buffer switching.

An unpaired 1048l is inert. pyte's restore_cursor homes the cursor when its
savepoint stack is empty (its documented DECRC behaviour), so a stray 1048l
teleported to the top-left — where both references, ignoring the mode entirely,
leave the cursor alone. A depth counter tracks only the saves this class pushed,
which keeps DECSC's stack semantics for repeated 1048h while making the unpaired
case do nothing. RIS resets it.

Verified under the smartcli_core exception: 19 deterministic gates green, 12 new
locks (including 1048-with-1049 in one CSI, repeated 1048h, and three unpaired
shapes), 4 mutations each caught, vendored copy re-synced, mypy at baseline, and
the fidelity suite green under BOTH the installed pyte 0.8.2 and a patched pyte.
Whether a full-screen program owns the screen changes what the next action MEANS:
`q` quits a pager but types a letter at a prompt, arrows navigate a menu but edit
a line. It was readable only as `model.screen.alt_screen` — reaching through to
the pyte object — so nothing in the semantic Snapshot an agent actually consumes
said so, and neither did any drive-tui reply.

Now on four surfaces: `ScreenModel.alt_screen`, `Snapshot.alt_screen`, the
`to_text()` header (inserted right after the cursor, ahead of the other flags,
because it changes how everything else should be read), the `to_json()` hints, and
the daemon's reply for every observing verb plus the `snapshot` stderr summary.

Verified through the real CLI against a real PTY: a REPL reports
`alt_screen=False`, the same session after emitting `ESC[?1049h` reports
`alt_screen=True` in the stderr summary, the text header, and the JSON hints
together; zero leaked sessions. 19 deterministic gates green, 9 new locks, 4
mutations each caught, vendored copy re-synced, mypy at baseline.
Repeated 1048h nests here (DECSC is a stack, and xterm defines 1048 as "save
cursor as in DECSC") but will OVERWRITE once _PYTE_HAS_ALT is true, because the
pending upstream implementation uses a single dedicated slot. Both are
defensible and neither reference emulator implements 1048 at all, so there is no
ground truth to prefer one — noted so the change is not read as a regression.
tests/_menu_app.py and its four siblings opened with `import sys, msvcrt`. On
macOS and Linux that raises ModuleNotFoundError before the app draws anything, so
_drive_probe3, _drive_probe5 and _drive_probe6 saw a traceback instead of a menu
and tests/run_all.py reported three failures with nothing to do with the code
under test. A POSIX run of the suite was permanently red.

That noise floor is the real cost: with four known-failing entries, a genuine
regression in those probes could not be distinguished from the platform gap. It
also hid these probes from CI, whose drive-smoke job runs only the test_* gates.

The apps already handled BOTH arrow encodings — the Windows \xe0/\x00 prefix and
the ANSI ESC [ A form — so the only thing tying them to Windows was that import.
tests/_kbd.py provides getwch(): unchanged msvcrt on Windows, and on POSIX a raw
tty read. Raw mode is entered ONCE and restored at exit rather than toggled per
keystroke, because toggling around each read would split an ESC [ A sequence
across three separate raw-mode entries — exactly what these fixtures must receive
intact.

_drive_probe3, 5 and 6 now pass on macOS, with zero leaked sessions.
… install

`python examples/drive_vim.py` puts examples/ on sys.path, not the repo root, so
`from smartcli_core import PtySession` could not see the package sitting one
directory up and the script exited with "pip install smartcli-toolkit". Asking an
example's readers to install first is reasonable; the problem is that
tests/run_all.py drives this file as a gate, where it has to work in-tree.

Falls back to the repo root only when smartcli_core/__init__.py is actually there,
so an installed copy still gets the original message.

Now 6/6 in-tree, and it is the only gate that verifies the alternate-screen work
end to end against a real editor: vim paints, alt_screen reads True, the typed
text lands, and the main screen comes back on exit.
`_Screen.delete_characters` adds one to the count when the cursor sits on a
two-column glyph, because pyte deletes cells without regard to width and left the
stub behind. If a pyte release ever does that itself, adding to it deletes one cell
too many — and with `pyte>=0.8.1` unpinned in both requirements.txt and
pyproject.toml, that would arrive as a dependency upgrade rather than a code
change, silently eating a character.

Not hypothetical: measured against a pyte carrying that fix, `中x` + CR + DCH went
from "x" to "". Same shape as the alternate-screen collision fixed earlier in this
branch — subclass and base class doing the same work — found while triaging which
of this project's screen-model fixes are still absent upstream.

There is no attribute to test for, so the capability is probed behaviourally once
at import: delete the wide glyph in `中x` on a throwaway 4x1 screen and see whether
`x` survives. The probe is wrapped so it can never break import.

Verified under THREE pyte builds, which is the only way this could be established:
stock 0.8.2, a checkout carrying the pending alternate-screen patch, and one
carrying six ported screen-model fixes including DCH. The fidelity suite is green
under all three. Mutation testing needed both ends — hardcoding the probe False is
only observable under a pyte that widens DCH, hardcoding it True only under one
that does not — and all three mutations were caught by one interpreter or the
other, none escaped both.
…t flake

_diff_fuzz_tmux failed in a full-suite run and passed standalone with the
identical argv — including immediately after _diff_two_refs, so it is not
ordering. By the time the suite reaches it, verify_fx and six drive probes have
already spawned dozens of PTYs, and the probe's settle-polling can return before
tmux has finished painting.

rerun=True is safe here specifically because the seed is FIXED: both attempts feed
tmux byte-for-byte the same 40 payloads, so a real divergence fails twice while a
slow capture fails once. The runner records "failed once then retried" either way,
so the flake stays visible rather than being swallowed.

With this and the POSIX fixture fix, tests/run_all.py is 43/43 on macOS — the
first full green here; it was 39/43 before, with four platform-gap failures acting
as a noise floor that would have masked a genuine regression.
…and 43/43

Adds HANDOFF 10i. The headline for a fresh session is not the pyte PR but the
two capability probes: both the alternate screen and DCH-over-wide-glyph would
double-apply once upstream ships them, arriving as a dependency upgrade rather
than a code change. Any screen-model change must now be verified under BOTH
stock pyte and a patched checkout.

Also records that run_all is 43/43 on macOS for the first time (it was 39/43,
with four platform-gap failures acting as a noise floor), which of the seven
other pyte defects survived triage, and the method notes that cost the most to
learn: three rig artifacts mistaken for divergences, a mutation harness made
non-deterministic by second-granularity .pyc validation, and two tests whose
expected values were copied from broken output.
Comment thread tests/_kbd.py
if _saved:
try:
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved)
except (termios.error, ValueError, OSError):
dwgx added 3 commits August 6, 2026 10:47
pyte copies `buffer[y] = buffer.pop(y + count)` only `if y + count in
self.buffer`, so when the source row was never written the DESTINATION keeps its
old contents instead of going blank. `insert_lines` already routed through
`_shift_lines` to keep every row present in the sparse buffer; DL had the
mirror-image hole and did not.

Two visible consequences, both measured against tmux 3.6b AND GNU screen 4.00.03,
which agree on all five cases now locked:

    Q  CR  ESC[1M        -> we left "Q" on screen; both references clear it
    A/B/C  ESC[2M        -> we gave ['C', 'B']; both give ['C']

The second is the worse one: DL(2) deleted a single row.

This survived because the earlier IL-side fix masked it. The repro recorded for
that fix happens to materialise the rows first, so it passed while the minimal
case stayed broken — a reminder that a passing repro proves the repro, not the
mechanism. Found by triaging which of this project's screen-model fixes are
genuinely still absent upstream; the triage also corrected the attribution, since
the record blames `insert_lines`, which pops unconditionally and is not at fault.

DL inside a DECSTBM region is deliberately left as-is and NOT asserted: that is
the one case where the two references disagree (tmux performs the insert, GNU
screen discards it), so there is no ground truth to match.

Verified: 5 new locks, 2 mutations each caught, 19 deterministic gates green, and
because this is a core drawing path — curated differential 35/35, generative fuzz
40 payloads clean on two seeds, three-way tmux+screen probe green, vendored copy
re-synced, mypy at baseline.
…sserting it

The triage claimed the six confirmed-live defects could be filed without waiting
on the alternate-screen PR, but its port was built ON TOP of that branch — the
145-test run gives it away, since untouched master is 117. So the claim rested on
nothing.

Extracted the 130-line diff and applied it to an untouched upstream/master
worktree: both files apply cleanly, suite stays 117 passed / 1 xfailed. The claim
holds, and now has evidence.

Process note, since it cost an hour: the diff was generated with ABSOLUTE paths,
so `patch -p0 -d pyte` resolved them literally, wrote back into the port itself,
and — prompted 'Reversed patch detected! Assume -R? [y]' — silently un-applied all
six fixes. Splitting the diff per file and passing patch an explicit target needs
no path guessing and cannot do that.
DECCOLM is the one pyte mode whose handler does real buffer work — ESC[?3h saves
saved_columns, resizes to 132, erase_in_display(2)s and homes the cursor — and the
alt-screen code changes what "the buffer" means, so the combination is the highest
priority thing neither review round had actually checked.

Probed six shapes: DECCOLM entered while on the alternate screen, width restored
after reset, over-wide cells after a shrink, saved_columns across a buffer switch,
which buffer the erase clears, and three full cycles. All six were already
correct. Locked the two that touch the saved primary screen, since that is the
part this branch changed; the rest would be testing pyte, not this code.

Green under all three pyte builds: stock 0.8.2, the pending alternate-screen
patch, and a checkout carrying six ported screen-model fixes.
@dwgx dwgx changed the title Fix the alt-screen differential rig, resize on the alt screen, and count drift Fix the alt-screen rig, six perception bugs, and two dependency timebombs Aug 6, 2026
dwgx added 9 commits August 6, 2026 10:59
Two reviewers disagreed about this one, and both were half right. With DECSTBM
margins that exclude row 0, a shrink through the alternate screen restores
different rows than the same shrink without one:

    no margins     direct ['CCC','DDD']   via alt ['CCC','DDD']   same
    margins (2,4)  direct ['AAA','BBB']   via alt ['CCC','DDD']   differ
    margins (1,3)  direct ['CCC']         via alt ['CCC','DDD']   differ

The margin sensitivity is pyte's, not this code's: it shrinks by homing the cursor
and calling delete_lines, which does nothing when the cursor sits outside the
scroll region, so the live buffer keeps its TOP rows. Isolated on a single buffer
with no alternate screen involved, stock pyte shows the same split.

Not matched here, and that is a decision rather than an omission. Real terminals
REFLOW on resize rather than clipping, and reset DECSTBM as part of it — pyte
itself calls set_margins() at the end of resize — so using the outgoing margins to
choose which rows to drop has no counterpart to measure against. Copying an
unverifiable rule into the second buffer needs a hacky buffer swap and would only
make the two symmetric, not correct. Same call the project already makes for IL/DL
from outside a scroll region, where the two reference emulators disagree: record
the divergence, do not pick a side.

The no-margins case is asserted; the margined ones print as notes so this stays
visible instead of being rediscovered as a bug.
An independent re-check of the upstream triage removed IL/DL from the list of
portable fixes, and it was right to. The `_Screen` docstring claimed "real
terminals keep the column" as though that were unanimous. It is not:

    column 0 (pyte's behaviour)  xterm, vte, and the DEC VT reference —
                                 terminalguide states it as "Moves the cursor to
                                 the left margin"
    column kept (ours)           tmux 3.6b, GNU screen, urxvt, konsole, linuxvc

Five documented implementations keep the column and two reset it, so this project
keeping it is defensible and the behaviour is UNCHANGED. What changes is how it is
described and what is done with it: pyte's own docstrings cite VT102/VT220, whose
reference says column 0, so a PR "fixing" it would move pyte away from the standard
it targets and would rightly be rejected. That is the ZWJ mistake nearly repeating,
caught one step earlier this time — and the overconfident docstring is what made
the wrong plan look sound.

Also corrected in HANDOFF 10i from the same re-check: the SGR colon-subparameter
defect already has an open upstream PR (#180, MERGEABLE since 2024-10-08) plus
issues #179/#178, so the bottleneck is maintainer review and filing again would
waste credibility; pyte #206 is actively rewriting the draw()/grapheme path that
any wide-glyph patch would touch; and defect 7's recorded evidence is stale while
the defect as named is still live under a 3-byte repro that folds into the
half-overwrite patch.
pyte clamps to the DECSTBM bottom margin unconditionally
(`min(cursor.y + count, bottom)`), so a cursor BELOW the region is dragged up into
it — CUD moving the cursor UP. Measured on tmux 3.6b and GNU screen 4.00.03, which
agree: region 3..6, cursor on row 8, `ESC[1B` lands on row 9; pyte and this class
both landed on row 6.

`index` and `cursor_up` were already overridden here for exactly this defect
class. Their mirror was not, and nothing tested it, so the same bug sat in place
through two rounds of fixing its siblings. An independent review found it by asking
why the third override was missing rather than by finding a failure — which is the
kind of gap a differential fuzzer will not surface, because it only generates
sequences, not absences.

Four shapes locked (below, inside, above, no region) plus the last-row cap, since
an override that forgets the screen bound would trade one clamp bug for a worse
one. 19 deterministic gates green, curated differential 35/35, generative fuzz
clean, 2 mutations caught, vendored copy re-synced.
The last-row check in the previous commit was a no-op assertion. Without a
DECSTBM region, `top <= y <= bottom` holds and pyte's own clamp runs, so the
override's `min(..., self.lines - 1)` was never reached — deleting that bound
left the test green. Mutation testing is the only reason that surfaced.

Now driven with the cursor OUTSIDE a region (region 2..3, cursor row 5, CUD 9),
which is the path the override takes, plus the no-region case kept separately so
both clamps stay covered. Both mutations caught: removing the override, and
removing its screen bound.
…D override

Seven agents, serialised, four hours. One HIGH survived verification and it was
mine: adding mode 1048 routed it through 1049's cursor slot, reintroducing the
defect the dedicated slot was created for one commit earlier. SmartCLI was immune
because its 1048 uses pyte's savepoint stack while 1049 uses a separate tuple.

Also records the cursor_down override that was missing from BOTH codebases —
found by asking why the third override was absent rather than by any failure,
which is the class of gap a generative fuzzer cannot reach — and two corrections
to the upstream plan: the SGR colon defect already has an open PR (#180), and E9
breaks an existing upstream test that the triager's port never covered.

Plus two blind spots in my own tests that only mutation testing caught: a paired
1048 sequence that cannot see a suppressed save, and a CUD row-cap assertion that
never entered the override it was meant to check.
…ding

The lint gate was failing on a passing tree. It installs only ruff and mypy, so
pyte was absent, ignore_missing_imports degraded pyte.Screen to Any, and a
correct `type: ignore[misc]` in the alt_screen property looked unused under
warn_unused_ignores. Nothing was wrong with the code; the gate was checking a
state that does not exist.

The same masking hid two genuine errors that have been present since v0.2.0 and
that CI has therefore never seen:

  screen_model.py: Argument 1 of "feed" is incompatible with supertype
                   "pyte.streams.Stream"  [override]
  screen_model.py: Cannot assign to a method  [method-assign]

Both are deliberate and both are now annotated at the call site rather than
silenced globally. The feed() narrowing is pyte's own Liskov violation —
Stream.feed takes str, ByteStream.feed narrows it to bytes and carries the
identical ignore upstream — so matching it keeps the override honest to the
class it inherits from. The write_process_input assignment is pyte's documented
device-query hook, which is a method rather than a callback attribute, so there
is no non-assigning way to install it.

Verified in both directions, because a one-sided run cannot tell a real fix from
one that happens to pass here:

  with pyte installed (the new CI state)  clean, 7 files
  without pyte (the old CI state)         reproduced the false unused-ignore

Mutation-verified that the gate still bites: returning str from a bool property
and passing bytes to cursor_down are both caught. Removing either new ignore
re-surfaces its error, so neither is decorative. One mutation — widening the
alt_screen return to int — was NOT caught, and that is correct: bool subclasses
int, so it was a bad mutation rather than a hole in the gate.

Real run path re-verified after the edit (annotations cannot change behaviour,
but the edited lines are the SGR-colon filter and the CPR reply hook, so this
was measured rather than assumed): SGR sub-parameter debris still absent,
CPR still answers ESC[6n, and a live `less` under a real PTY still reports
alt_screen True on entry and False after quit, with zero leaked sessions.
…asymmetry

The daemon has handled a `resize` action since the control-plane hardening and
the MCP server exposes it, but the CLI had no verb — so the one surface a human
drives by hand was the only one that could not resize a live session. SKILL.md
documented the gap as MCP-only rather than closing it (NEXT-STEPS A0-CLI-RESIZE).

Validation deliberately stays in the daemon. It converts _validate_size's
SystemExit into an error REPLY, because SystemExit is a BaseException and would
otherwise sail through the per-connection `except Exception` guard and tear down
the whole session — the bug fixed during the v0.2.0 review. _call then turns that
reply back into SystemExit for the CLI caller, which is the shared convention for
every verb in this file, so a rejected size exits non-zero with the daemon's own
message.

That convention cost a round: the handler first carried its own error branch,
which was dead code (_call never returns on an error reply) and doubled the
"error:" prefix that _validate_size already includes. Removed rather than
worked around.

Verified on a live PTY, one session at a time, zero leaks after each:
  80x24 -> resize 100x30    our grid reports [screen 30x100]
  --json                    {"ok": true, "sid": ..., "cols": 90, "rows": 28}
  99999x99999               exits 1 with the daemon's limits message, and
                            `alive` still reports alive afterwards — the
                            rejection does not kill the session
The PTY side needs no new proof: PtySession.resize already drives both
TIOCSWINSZ (POSIX) / setwinsize (ConPTY) and the pyte grid together, so the verb
inherits a path the probes already cover. test_drive_security stays green.
The MCP snapshot tool rebuilt its reply as a hand-written allowlist dict, and
alt_screen was not on the list. The daemon has always sent it (tui.py sets it
unconditionally) and the CLI has always printed it, so MCP was the one surface —
the one this project promotes hardest, 14 tools, live on the registry — where a
client could not tell whether a full-screen program owned the screen. That is
precisely the blindness the alternate-screen work existed to remove, surviving on
the surface nobody re-checked.

Verified end-to-end through the real MCP tool registry rather than by reading the
diff: start real `less`, call the snapshot tool, get alt_screen True; send q, get
False. The returned keys are now
['ok','alive','alt_screen','text','hash','visual_hash'].

Docs: alt_screen reached the code in the previous session but no document an agent
reads. `grep -rln alt_screen --include='*.md' .` matched only CHANGELOG and
HANDOFF. Now in SKILL.md's snapshot-header field list and its screen-classification
cases, in core_api.md's Snapshot fields, and as a LIMITATIONS.md "fixed" entry.
Header order documented as measured, not guessed: alt_screen leads the flags
(`[screen 4x40] cursor=r0c4  alt_screen  cursor:hidden`) because it changes what an
action MEANS.

Three claims in the new LIMITATIONS entry were measured before writing them down:
the cursor is NOT homed on entry ((2,6) before 1049h, (2,6) after), 47/1047 switch
without the cursor save and keep the primary text, and the alt buffer does not
persist across a round trip. Mode 1048 keeps its weaker evidence level stated
verbatim — xterm defines it, neither reference emulator implements it — because
there is no ground truth to promote it with.

Also retired two stale claims: the tmux launchers are verified (2026-07-27, real
tmux 3.6b, 18/18) rather than unverified, and Environment notes now record that the
core branches on the installed pyte's capabilities, so behaviour can change from a
`pip install -U` with no code change here.

SKILL.md's "resize is MCP-only" caveat is gone and resize joins both verb lists,
following the CLI verb added in the previous commit.

test_doc_counts, test_version_sync, test_vendor_sync, test_dependency_sync and
test_drive_security all green.
NEXT-STEPS.md calls itself the single source of truth for what to do next and
claimed a 2026-07-27 reconciliation, but `grep -c "1048\|CUD\|cursor_down\|#212\|
capability"` returned 0 — it knew nothing about HANDOFF §10h or §10i. Worse, its
ground-truth snapshot still said the latest RELEASED version was v0.1.8 and that
v0.2.0 was code-complete but unreleased on a branch. v0.2.0 shipped on 2026-07-27.
A queue that stale does not just go unread; it sends the next session to redo
finished work.

Reconciled: the release snapshot now matches `git tag` and names the two
post-release sessions that landed on main without a version bump. Suite size
recorded as 43 entries / 43-43 green on macOS, with the note that the four earlier
failures were platform gaps in test fixtures (msvcrt imports, a drive_vim import)
rather than product bugs — that distinction is the whole reason the noise floor
mattered.

Added A0-PYTE-UPSTREAM as a properly framed task for the six pyte defects measured
to be live on master AND independent of the unreviewed #212, and — the part worth
more than the task itself — the four things that must NOT be filed: IL/DL cursor
column (pyte matches the standard it targets; we are the deviation), ZWJ cluster
width (master already picked tmux's side), SGR colon sub-parameters (pyte #180 is
already open and mergeable; the bottleneck is review, not reporting), and the
orphaned wide stub (not a pyte defect at all — the orphan is manufactured by our
own draw() override). Each of those was a wrong upstream plan that an independent
re-check caught, so recording the correction is the point.

A0-CLI-RESIZE struck as DONE with its verification, including the detail that a
rejected size must leave the session alive.
# _call then turns that reply back into SystemExit for the CLI caller, which
# is the shared convention for every verb here — so a rejected size exits
# non-zero with the daemon's message and never reaches the lines below.
resp = _call(args.id, {"action": "resize", "cols": args.cols, "rows": args.rows})
dwgx added 2 commits August 6, 2026 17:59
The fix in 7f5bc3b had no test, so the field could silently fall off the
snapshot tool's hand-written allowlist again. Three locks, mutation-verified:

  revert the fix entirely      4 checks FAIL (field reads None)
  hardcode alt_screen=False    1 check FAILS — only the real-program one

That second result is the reason the probe drives real `less` rather than
asserting False on a REPL. A missing key and a genuine False are identical to
`.get()`, which is exactly how the omission survived review in the first place;
asserting `"alt_screen" in r` catches the drop, but only a screen that is
actually in the alternate buffer catches a wrong value.

Two rig traps hit while writing this, both already recorded in HANDOFF and both
re-confirmed the hard way:

- A REPL cannot write the escape for you. `python3 -i -q` on a PTY does not
  execute queued lines promptly, so `sys.stdout.write('\x1b[?1049h')` was only
  ECHOED, never run — and the wait pattern then matched the echo of the probe's
  own payload. That is a PASS proving nothing (HANDOFF 10g). The first version of
  this test did exactly that and reported alt_screen False against a screen that
  had never entered the alternate buffer.
- `less` is spawned WITHOUT -X deliberately: -X disables the alternate screen,
  i.e. the feature under test (HANDOFF 10i).

The new session starts only after the first is closed and polled leak-free, so
the one-PTY-at-a-time red line holds; it SKIPs where `less` is absent rather
than failing, matching the tmux probes' convention. Full probe ALL PASS, zero
leaked sessions.
NEXT-STEPS D1 had been open for weeks: the /deep-research anchor list tuned during
the competitive-benchmarking work lived only in session context.

RESEARCH-PROMPTS.md records five anchors — conch, terminal-bench/Harbor, plotille,
TTE, PyPI trusted publishing — each with the question, a Last-checked line, and what
a good answer would CHANGE in the backlog. That last part is the point: an anchor
whose answer changes nothing is not worth re-running, so pexpect, Textual and
pytest-textual-snapshot are recorded as already benchmarked with no open
sub-question instead of being kept as perpetual TODOs.

It also prices the answers honestly. "Port one more effect" is not a small edit
here: it has to clear test_fx_contract, move verify_fx from 38/38 to 39/39, and
update every count site test_doc_counts gates across README, i18n READMEs, SKILL.md
and the site pages. Writing that down is what stops a future session promising a
cheap catalog bump.

Claims spot-checked against disk rather than taken from the anchor list: the TTE
snapshot (research/R1-effects-catalog.md PART C), the publish.yml action pin
(pypa/gh-action-pypi-publish@release/v1), braille_chart.py's existence, and the
38/38 verify_fx figure.

test_doc_counts FAILED on the first attempt at this commit and the fix is worth
recording: the D1 result note quoted TTE's upstream catalog size as a bare number,
which the anti-drift gate read as an fx effect count and demanded 30. The gate was
right — a naked effect number in a shipping doc is exactly what it exists to catch —
so the figure is named rather than digitised. Gate green now.
The lock added in 86dc332 passed here and failed on all three runners — the
textbook shape this project keeps re-learning: an assertion that passes on the
machine that wrote it.

Cause, reproduced locally with `env -u TERM` rather than guessed: CI runners have
no TERM, and without one `less` prints

    WARNING: terminal is not fully functional
    Press RETURN to continue

and never enters the alternate screen. So the feature under test did not happen
and the check failed for a purely environmental reason. This is the same class as
`less -X` disabling the alternate screen and GNU screen defaulting `altscreen`
off (HANDOFF 10i): three times now, the rig has been the thing that suppressed
the feature it was pointed at.

Fixed by passing TERM explicitly through the MCP start tool's own `env` parameter,
so the probe no longer depends on the host's shell at all. Verified BOTH ways —
with TERM present and with `env -u TERM` — because a one-sided run cannot tell a
real fix from one that happens to work here.

The wait marker was also wrong twice, and both are worth recording. A bare "1"
matches the digits in the warning screen itself, i.e. it would pass in exactly the
environment where the alternate screen never happens. "200" looked safer but is
the LAST line of a 200-line fixture, and the measured first page of a 24-row
screen is lines 1..23 plus the status bar — so it never appears either. The marker
is now the fixture's filename, which only less draws, matched with re.escape
since it is a path.

Mutations re-verified through the corrected path: reverting the fix fails 4
checks, hardcoding alt_screen=False fails only the real-program one.
@dwgx
dwgx merged commit 51cfaa9 into main Aug 6, 2026
28 checks passed
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.

2 participants