Skip to content

fix(terminal): restore the history anchor after xterm parses, not before - #424

Open
Ark0N wants to merge 1 commit into
masterfrom
fix/terminal-history-anchor-after-parse
Open

Ark0N wants to merge 1 commit into
masterfrom
fix/terminal-history-anchor-after-parse

Conversation

@Ark0N

@Ark0N Ark0N commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Fixes #358.

The bug

flushPendingWrites() captured the viewport of a user reading scrollback, called terminal.write(), and restored the anchor on the very next line:

this.terminal.write(writeChunk, () => { /* ... */ });
if (preserveViewportY !== null && this.terminal.buffer?.active?.viewportY !== preserveViewportY) {
  this.terminal.scrollToLine(preserveViewportY);
}

xterm parses on its own schedule, so at that moment the buffer has not moved. viewportY still equals the anchor, the guard is false, and scrollToLine is never called at all. The parse lands a tick later, a cursor-addressed Codex redraw takes the viewport to the live bottom, and nothing is left to pull it back. That is the timing gap the issue describes, and it is why a refresh was the only way back to a coherent view.

The existing regression passed the whole time because its mock moved the viewport synchronously inside write(), which real xterm never does. A test that models the renderer wrongly is a test that certifies the bug.

The fix

The restore moves into xterm's write callback, the first moment at which the redraw's effect exists, and runs before _scheduleTerminalWriteFlush() so a deferred remainder re-captures the restored anchor rather than the bottom.

Two consequences of it running later, both handled:

  • A live anchor now wins over the sticky scroll-to-bottom. The two are captured at different moments (_wasAtBottomBeforeWrite at the frame's first batchTerminalWrite, the anchor at flush time), so a scroll-up in between leaves both set. Previously the sync restore made that harmless; now running both would jump to the bottom and come back a frame later instead of simply staying put.
  • The anchor is dropped if the active session changed, or a buffer load started, while the write was in flight. It is a row index into the buffer it came from, and selectSession() resets the terminal and chunk-loads a different scrollback, so replaying row 40 into that one would be a jump to an arbitrary place rather than a restore. This window did not exist while the restore was synchronous.

Tests

test/terminal-flush-budget.test.ts grows a shared harness whose write() parses asynchronously the way xterm does (the redraw lands, then the callback fires), and seven cases on top of it:

  • restore happens only after the parse, and not before it;
  • the anchor holds across consecutive redraws;
  • it holds across a chunked write whose remainder is deferred;
  • no bounce off the bottom when the sticky flag and an anchor disagree;
  • dropped on a session switch mid-write;
  • dropped while a history replay owns the viewport;
  • the original fix(mobile): preserve terminal scroll position across keyboard resize and replay #259 case, rewritten onto the async model.

Five of them fail against master, including the rewritten original. Full gate green on this branch, rebased onto 1.29.0 and run on an idle machine: 383 files passed, 0 failed, 7256 tests passed.

Not in this PR

The issue also asks for a browser smoke test using synchronized, cursor-addressed redraws while scrolled up. That suite (test/browser) is excluded from the CI gate, so a guard living only there would be invisible to CI, which is the blind spot that let two semantically conflicting mobile PRs merge green once before. The vm-level tests here drive the real flushPendingWrites() against a faithful model of xterm's async parse, and CI can actually see them.

flushPendingWrites() captured the viewport of a user who was reading
scrollback, called terminal.write(), and restored the anchor on the next
line. xterm parses on its own schedule, so at that point the buffer has not
moved: the guard `viewportY !== preserveViewportY` was false, scrollToLine
was never called at all, and the Codex redraw landed a tick later and took
the viewport to the live bottom with nothing left to pull it back. Scrolling
up during a stream still got dragged down, which is what #358 reports, and a
refresh was the only way back to a coherent view.

The restore moves inside xterm's write callback, the first moment the
redraw's effect exists, and runs before _scheduleTerminalWriteFlush() so a
deferred remainder re-captures the restored anchor rather than the bottom.

Two things follow from it running later:

- A live anchor now wins over the sticky scroll-to-bottom. The two are
  captured at different moments (_wasAtBottomBeforeWrite at the frame's
  first batchTerminalWrite, the anchor at flush time), so a scroll-up in
  between leaves both set, and running both would jump to the bottom and
  come back a frame later instead of staying put.
- The anchor is dropped if the active session changed or a buffer load
  started while the write was in flight. It indexes the buffer it was
  captured from, and selectSession() resets the terminal and chunk-loads a
  different scrollback.

The existing regression passed throughout, because its write mock moved the
viewport synchronously, which real xterm never does. The harness now models
an asynchronous parse (redraw lands, then the callback fires), and all five
of the anchor tests fail against the old code.

Fixes #358

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ark0N
Ark0N force-pushed the fix/terminal-history-anchor-after-parse branch from 7d02423 to de864e7 Compare September 14, 2026 22:11
@Ark0N

Ark0N commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto 88e3faa4 (1.29.0). Clean, no conflicts, and no semantic overlap either: of the two commits the release added to terminal-ui.js, one is the selectionBackground rename from #423 and the other is b3a6ba2e (Shift+drag select, right-click copy), and neither touches flushPendingWrites or the sticky-scroll path.

Full gate on the rebased branch, run on an otherwise idle machine: 383 files passed, 0 failed, 7256 tests passed, 12 skipped. CI on the pushed head is green on both jobs.

Worth noting for the record, since it affected my two earlier runs: each of those reported one failed test file with zero failed tests, a different file each time (EADDRINUSE 127.0.0.1:3197 in test/base-path-server.test.ts, then ENOTEMPTY in test/setup.ts's temp-HOME teardown). Both were another full gate running on the same machine at the same moment, and both passed when run alone. This run had the box to itself and is clean throughout.

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.

fix(terminal): Codex redraws corrupt the viewport when scrolling during streaming

2 participants