Fix the alt-screen rig, six perception bugs, and two dependency timebombs - #8
Merged
Conversation
… 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.
| if _saved: | ||
| try: | ||
| termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved) | ||
| except (termios.error, ValueError, OSError): |
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.
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}) |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Started as three small fixes found while contributing the alternate screen buffer
upstream to pyte (selectel/pyte#212,
closing a 9-year-old
help-wantedissue). Two rounds of adversarial review turnedit 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.pyis 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.1is an openrange in
requirements.txtandpyproject.toml.The alternate screen. pyte has never implemented it (issue #90, open since
2017), which is why
_Screendoes. The day #212 ships in a release, both layersswitch and the primary screen restores blank. Measured against a patched pyte:
a
smcup PAGER rmcupround trip gave['', '', ''].DCH over a wide glyph.
delete_characterswidens the count because pytedeletes 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_ALTviahasattr,_PYTE_DCH_HANDLES_WIDEvia a one-shot behavioural probe, since there is noattribute for that one) rather than pinning
pyte<0.8.3. A cap would keep usersoff 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_altassigned 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
displaynever renders. Cursorat row 6 of 8, smcup, resize to 3 rows, rmcup → cursor
(6,3)on a 3-row screen,and
AFTERwritten next was invisible. The agent reads a screen that hasstopped updating, with nothing reporting a problem.
DL did not blank the rows it vacated. pyte copies
buffer[y] = buffer.pop(y + count)onlyif y + count in self.buffer, so a sourcerow 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[1MleftQon screen, andA/B/C+ESC[2Mdeleted a single row, giving
['C','B']where both references give['C']. Theearlier 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.pydrove GNU screen withoutaltscreen on, which defaultsoff. 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 diedbefore drawing anything, so three drive probes saw a traceback instead of a menu.
With
examples/drive_vim.pyunable to import from a checkout, that was a four-entrynoise floor in which a genuine regression was indistinguishable from the platform
gap.
tests/_kbd.pynow provides a cross-platformgetwch()— raw mode enteredonce, not per keystroke, or an
ESC [ Agets split across three separateraw-mode entries.
_diff_fuzz_tmuxhas a load-dependent flake, failing in a full-suite run andpassing standalone with identical argv.
rerun=Trueis safe because the seed isfixed: 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 -Xdisables the alternate screen (the feature under test), GNU screen's
altscreendefaults off, and a
git stash pushwith nothing staged meant a control experimentran the same code twice. "Suspect the rig first" has a measured hit rate here.
The mutation harness itself was non-deterministic. Python validates a
.pycagainst (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)andnever 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
1048lis inert rather than teleporting the cursor the way pyte's empty-stackrestore_cursorwould.alt_screenreached the surfaces an agent actuallyreads (
ScreenModel,Snapshot, theto_text()header, JSON hints, everydrive-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.mdandrun_all.pycorrected, andHANDOFF.md§10i records all of it.Tested
43/43
run_all.py(up from 39/43), 19 deterministic gates, curated differential35/35, generative fuzz clean on two seeds, three-way tmux+screen probe green, a
real
lessdriven over a real PTY 10/10,drive_vim6/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.