Skip to content

fix(terminal): keep the viewport pinned across resize/redraw reflows (#403)#412

Merged
khaliqgant merged 1 commit into
mainfrom
fix/residual-divergence-vector
Jul 17, 2026
Merged

fix(terminal): keep the viewport pinned across resize/redraw reflows (#403)#412
khaliqgant merged 1 commit into
mainfrom
fix/residual-divergence-vector

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jul 17, 2026

Copy link
Copy Markdown
Member

Fixes the residual intermittent terminal divergence on relay 10.6.3 tracked in #403 (codex resize-mid-stream, ~1-in-2). Authored by fixer-residual (term-fidelity program); coordinated by lead in #term-fidelity.

What the bytes proved (root cause)

This vector is not byte loss and not predictive echo:

  • differingCells=0 — rendered cells match the broker oracle exactly. Replaying the exact delivered PTY bytes (tapped at onPtyChunk, post main-dedup) through a clean headless xterm equals the broker snapshot (rawVsBroker=0). Delivery is byte-clean; the live grid is a faithful render of the delivered bytes.
  • The real failure is the fidelity quiet gate (viewportAtBottom): at quiet the viewport is stranded in scrollback — viewportY=0, baseY=100 — with byte-correct content, so the gate never closes. A single scrollToBottom() fully repairs it (probe: [0,100] → [100,100]).
  • The headline [39,2] vs [40,3] cursor is a reporting-convention artifact, not corruption: the stream's final cursor op is literally ESC[40;3H; xterm's 0-indexed buffer reports [39,2], the broker snapshot reports the 1-indexed CUP coords [40,3]. hasPredictions=false throughout — the erasePredictionRegion / relay#1326 path is not involved here.

A per-frame viewportY/baseY trace across the resize storm showed two independent unpin sources, both defeated by the same flaw:

  1. Write path — a TUI SIGWINCH full-screen redraw (a server write) scrolls the viewport off-bottom during xterm's async parse; the synchronous re-pin ran before the parse, so it didn't stick.
  2. Fit path — a width/height reflow (fitAddon.fit → term.resize; unpin observed exactly at rows 45→44, 43→42) scrolls the viewport off-bottom; the ResizeObserver re-pin reads the instantaneous wasPinned, which a prior reflow already flipped false.

Common flaw: "am I following the tail?" was inferred from the instantaneous viewportY === baseY. Once any transient reflow/redraw unpins by even one line, that reads false and every later write and fit stops re-pinning → the grid freezes in scrollback.

Fix

Track a sticky followBottom intent, flipped only by a real user wheel scroll, and re-pin against that intent (not the instantaneous position):

  • echo-router.ts — direct-route re-pin now fires from the write-completion callback (after xterm parses the chunk), so a chunk that scrolls during its own parse is corrected post-parse.
  • terminal-runtime-registry.tsfollowBottom (wheel-driven) + a re-pin centralized in tryFit, so every reflow site (ResizeObserver fit, reconciler onPersistentDimsMismatch, init, refreshOnShow) keeps a following viewport at the tail.

A deliberate wheel-up into scrollback clears the intent and is left alone (scrollback workload safe). Invariants intact: no bytes are dropped, and the reconciler confirm-twice / rate-limit / dims gates are untouched.

Verification

  • Empirical: npm run test:term-fidelity -- --cli=codex (resize-mid-stream), 12/12 clean with the fix, from a ~1-in-2 pre-fix baseline (p≈2e-4 by chance). An earlier write-path-only fix still failed 1/4 (the final fit unpins after streaming ends), which is why the fit path is also covered.
  • Unit: 71 focused renderer tests green (echo-router, terminal-runtime-registry.dom, use-terminal.dom, terminal-reconciler, pty-size-sync, pty-buffer-store), including 3 new regression tests locking (a) the post-parse write re-pin and (b) the reflow re-pin gated on wheel-driven follow intent.
  • npm run typecheck clean; scoped ESLint clean; npm run build clean.
  • Isolated brokers only; the live pear broker / port 3889 were never touched; all work on a git worktree.

Note: the opencode typing-during-stream case (reconciler fired+repaired) is a different vector (genuine content divergence) and is not addressed here — captured separately.

🤖 Generated with Claude Code

Review in cubic

…403)

The residual intermittent divergence on relay 10.6.3 (codex resize-mid-stream)
is NOT byte loss and NOT predictive echo: the rendered cells match the broker
oracle exactly (differingCells=0; replaying the delivered bytes through a clean
emulator equals the broker). The failure is the viewport being stranded in
scrollback — at quiet it sits at [viewportY=0, baseY=100] with byte-correct
content, so the fidelity quiet gate (viewportAtBottom) never closes. (The
"[39,2] vs [40,3]" cursor signature is a 0-indexed xterm vs 1-indexed broker
reporting artifact — the stream's last CUP is literally ESC[40;3H — not
corruption.)

Root cause: "am I following the tail?" was inferred from the INSTANTANEOUS
viewportY === baseY. Two paths transiently scroll the viewport off the bottom:
a TUI's SIGWINCH full-screen redraw (a server write, off-bottom DURING xterm's
async parse) and a width/height reflow (fitAddon.fit -> term.resize). Once the
viewport is off by even one line, the instantaneous check reads false and every
later write AND fit stops re-pinning — the grid freezes in scrollback.

Fix: track a sticky `followBottom` intent, flipped ONLY by a real user wheel
scroll. Re-pin against that intent (a) in the echo-router's direct-route write
COMPLETION callback, so a chunk that scrolls during its own parse is corrected
after the parse, and (b) centrally in tryFit, so every reflow site
(ResizeObserver, reconciler onPersistentDimsMismatch, init, refreshOnShow) keeps
a following viewport at the tail. A deliberate wheel-up into scrollback clears
the intent and is left alone (scrollback workload safe). No bytes are dropped
and the reconciler confirm-twice / rate-limit / dims gates are untouched.

Verified: 12/12 clean codex resize-mid-stream matrix runs (from a ~1-in-2
baseline); 71 focused renderer tests green including new regression tests that
lock the post-parse write re-pin and the reflow/wheel intent behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@khaliqgant khaliqgant added the no-agent-relay-review Disable agent-relay automated PR review/fixes label Jul 17, 2026
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b59fe93d-8f18-4510-84bb-ca7388ab3c61

📥 Commits

Reviewing files that changed from the base of the PR and between a52d3a2 and d764851.

📒 Files selected for processing (4)
  • src/renderer/src/lib/echo-router.test.ts
  • src/renderer/src/lib/echo-router.ts
  • src/renderer/src/lib/terminal-runtime-registry.dom.test.ts
  • src/renderer/src/lib/terminal-runtime-registry.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/residual-divergence-vector

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@khaliqgant
khaliqgant marked this pull request as ready for review July 17, 2026 17:28
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@khaliqgant
khaliqgant merged commit 26f42d9 into main Jul 17, 2026
4 checks passed
@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@khaliqgant
khaliqgant deleted the fix/residual-divergence-vector branch July 17, 2026 17:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d7648518aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +308 to +310
host.addEventListener(
'wheel',
() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track scrollbar-driven changes to follow intent

Listening only for wheel misses deliberate scrolling performed by dragging xterm's scrollbar. In that case followBottom remains true, so the next direct write completion or tryFit() calls scrollToBottom() and immediately yanks the user out of scrollback; dragging the scrollbar back to the bottom likewise cannot re-arm a previously cleared intent. Track the terminal viewport's actual user-driven scroll changes rather than only wheel events.

Useful? React with 👍 / 👎.

khaliqgant added a commit that referenced this pull request Jul 17, 2026
…criminator

Two diagnostic improvements to the term-fidelity matrix, from the #403 fixer
investigation (lead-endorsed evidence-first method).

1. Cursor-convention normalization (bundle meta). The renderer cursor is xterm's
   0-indexed [row,col]; the broker plain-snapshot cursor is 1-indexed (it mirrors
   the raw CUP coords — a stream ending in ESC[40;3H reports broker [40,3] while
   xterm reports [39,2] for the SAME cell). Comparing the raw values makes a
   faithful render look like an off-by-one bug — this misread #403's headline
   "[39,2] vs [40,3]". Meta now records the raw values WITH their conventions,
   both normalized to a common 0-indexed base, and a computed `match` bool: a
   false there on an otherwise content-matching bundle is a REAL cursor
   divergence, not the convention.

2. Raw-stream divergence discriminator. Tap the raw broker->renderer byte stream
   (onPtyChunk, post main dedup), and on any divergence — including a
   telemetry-only reconciler repair — replay those exact bytes through a clean
   headless xterm at broker dims and diff vs broker and vs the live grid:
     rawVsBroker==0 -> delivery byte-clean; divergence is RENDERER-INTERNAL
     rawVsBroker >0 -> bytes lost/reordered before the renderer (DELIVERY-LOSS)
   Plus a scrollToBottom probe (stranded-viewport vs real content corruption),
   PEAR_DIAG_PTY per-chunk logging, and renderer-console capture. Writes
   raw.bin / raw-replay.txt / discriminator.txt / renderer-console.log into the
   bundle (and the reconciler-telemetry dir). This is what classified #403 as a
   viewport-pin regression (not byte loss) and #415 as delivery-clean.

Test-only; no production/renderer code changed. Refs #403, #412, #415.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-agent-relay-review Disable agent-relay automated PR review/fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant