Repository navigation
fix(web): recover the tmux repaint gap above the live screen on the first scroll-up (#814) - #815
HadrienKerlero wants to merge 2 commits into
Conversation
…irst scroll-up (Ark0N#814) A tmux repaint burst overwrites rows that never reach xterm scrollback, leaving a hole just above the live screen. The Ark0N#205 re-pull only fired at viewportY 0, so the user had to scroll past the hole first. Mark a non-shell session dirty on each live write; the first upward scroll from the bottom of a dirty session runs the existing full-history re-pull anchored to the bottom, keeping the viewport's distance from live output and replaying travel made during the replay. Cooldown, downgrade guard and the top-of-buffer path are unchanged.
…ve (Ark0N#814) Review follow-up. The early re-pull sampled the live-output generation after its fetch, but nothing gates live output while the capture downloads, so output newer than the capture was overwritten and the session marked clean. Sample before the fetch. Travel made during the replay was one signed sum clamped once, so scrolling to the bottom and back up cancelled out. Record moves in order and clamp after each, as live scrolling does.
|
Thanks for this, Hadrien, and for the careful write-up. The PR repairs the tmux repaint gap from #814 by re-pulling the full tmux history on the first scroll-up from the live screen, instead of waiting until the scroll reaches the top of the buffer. The open design questionOne decision on #814 is still open, and this PR makes it: whether the history repair may fire before the top of the buffer, and on which signal. That has to be settled before more work goes into the code. The signal here is any live output. There are narrower options, and picking one is the open part: keep the top-only behaviour, use a stream signal that only fires on a burst (the reproduction on #814 found that tmux sends a full-screen Code points, if the any-output trigger is kept
Typecheck, lint, format, the frontend syntax and dead-code checks, and the full test suite (17134 tests) pass on this branch, and 9 of the new tests fail on master as intended. The trigger question is open, so the PR stays as it is for now. The code points above only apply if the any-output trigger is kept. |
Fixes #814.
Problem
In a non-shell session (seen with OMP), scrolling up from the live screen shows a hole: recent output just above the screen is missing and the rows jump to older history. tmux still holds those rows. When output outpaces tmux's flush interval, tmux repaints the pane rectangle instead of emitting linefeeds, so rows that were on screen get overwritten and never reach xterm's scrollback. The repair for this (#205,
_maybeRefetchFullHistory) only runs when the viewport reachesviewportY === 0. The gap sits near the bottom of the buffer, so the user has to scroll past it to the top before the history is fixed.What this changes
Frontend only:
src/web/public/app.jsandsrc/web/public/terminal-ui.js.flushPendingWritessets_liveOutputSinceFullPulland increments_liveOutputGen[sessionId]for every live chunk it writes, but not while_isLoadingBufferis set, because a replay cannot open a new gap. A full-history replay clears the flag only if the generation has not changed since its capture could have been taken. The scroll re-pull samples the generation before its fetch, because nothing gates live output while the capture downloads. The tab-switch?full=1load samples it at the replay, because it already queues live output during its fetch, and it clears the flag only if itcompletedwithout falling back to the tail. Live output newer than the capture therefore keeps the session marked, and the next scroll-up pulls again._maybeLoadMoreHistoryOnScroll. If the gesture is upward, started at the live bottom (viewportY >= baseY - 2 - |lines|, which allows for the travelscrollLinesalready applied), the session is notshelland is marked, and no pull or load is running, it calls_maybeRefetchFullHistory({ anchor: 'bottom' }). This hook is only reached from Codeman's own wheel, touch and smooth-scroll routes. Scrollbar drags and keyboard scrolling do not get the early repair, and still depend on the unchanged top-of-buffer path, as before.anchor: 'bottom'in_maybeRefetchFullHistory. The existing restore keeps the position measured from the top, which is right when the user is at row 0. Here the user is a few rows above the live screen, so the restore keeps the distance from the bottom instead. While the replay runs, the reset buffer cannot scroll. Wheel and touch moves are recorded in order in_earlyReplayTraveland replayed once the replay ends, each clamped to[0, baseY]as it would have been live, so scrolling down to the bottom and back up does not cancel out. That way the gesture is neither lost nor fed into the top-of-buffer path partway through. If that travel reaches the top, the partial-history notice is still shown.The existing guards still apply unchanged: the 4 s cooldown, the 60 s latch after a useless pull,
_replayWouldShrinkBuffer, the select-generation check, detached sessions, and tiles.Why this shape, and what I rejected
baseYwith_paneHistoryLines. I did not do that._paneHistoryLinesis recorded at tab switch, so it is stale exactly when it matters, after a burst. A fresh#{history_size}would need a probe round trip, and probably a new server route, for every scroll-up. The dirty flag is free and always current. The cost is that it is a coarser proxy (see below).Downsides and risks
?full=1capture: tmuxcapture-paneover up tohistory-limitlines on the server, plus a full reset and chunked replay in the browser. The cooldown limits this to one pull every 4 s, and the flag limits it to once per batch of output. On a long session this is still far more capture traffic than before, when it happened only at the top of the buffer. On mobile data it also costs bandwidth. I think this is the main thing to review._replayWouldShrinkBufferand latched for 60 s. That is correct, but it still costs one wasted capture per minute per session on scroll-up. The test for this case checks that the buffer is not reset and the flag stays set._earlyReplayTravelis one app-level field, not per session. It works because only one pull can run at a time (_fullHistoryRepullInFlight) and thefinallyalways resets it. It is still shared state that a future concurrent path could break.Tests
New
describeblock intest/shell-scroll-history-pull.test.ts("early full-history recovery above live output (#814)"). It covers:The shared
makeAppfixture now setsviewportY: 0, because the scroll path reads it. The reversal and download-time tests fail on the previous version of this branch and pass now.These are VM-loaded unit tests with a mocked xterm. They pin the logic but not real xterm behavior during a reset and replay, nor the real buffer-load queue. They do not cover an early replay cancelled by a tab switch, or the tab-switch path clearing the flag. No browser test covers this path. I did not run
npm run test:browser.Run on this branch (based on
master@e99b42c3):npm test(on the first commit): 757 files, 17130 passed, 13 skipped, 1 failed. The failure isfrontend-public-tooling.test.ts > runs the public asset check script. It failed because my worktree lacked the gitignoredsrc/web/public/vendor/*bundles thatpostinstallbuilds. With those copied in, the file passes 4/4. It is unrelated to this change.npm run lint,format:check,typecheck,check:frontend-syntax,check:frontend-dead,check:browser-excludes: pass on both commits. The pre-push hook passed on the second push.npm run build: passes.Manual verification: not done
The first commit was built into a local 1.51.0 install (Linux, tmux 3.4, OMP 18.20.0, Zen Browser), but I have no recorded before/after observation that it removes the gap. The follow-up commit has not been run in a browser at all. Please treat the behavior as unverified until someone reproduces #814 and checks that the first scroll-up after an OMP burst shows the recent rows without a jump.