Skip to content

fix(pi-tui): stop scrollback duplication from viewport rewinds#1353

Merged
liruifengv merged 4 commits into
mainfrom
fix/pi-tui-scrollback-dup
Jul 3, 2026
Merged

fix(pi-tui): stop scrollback duplication from viewport rewinds#1353
liruifengv merged 4 commits into
mainfrom
fix/pi-tui-scrollback-dup

Conversation

@liruifengv

@liruifengv liruifengv commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Related Issue

No linked issue; follow-up to #1315, the problem is explained below.

Problem

#1315 introduced viewport re-anchoring: when content shrinks, the renderer rewinds the viewport anchor and repaints so the input area snaps back to the screen bottom. But terminal scrollback is append-only — rewinding the anchor repaints rows that scrollback already holds, and the next scroll commits them again. Every rewind therefore duplicated its span in scrollback.

Streaming makes content length oscillate constantly (thinking finalizing, activity panes collapsing, step summaries merging), so long turns stacked duplicate blocks of transcript content in scrollback — messages and tables visibly appearing twice when scrolling back.

What changed

The viewport anchor is now treated as the scrollback high-water mark and never moves backward, with all shrink/shift handling built on a shared viewport repaint:

  • Partial shrinks keep the anchor pinned: the content bottom hovers above a bounded blank gap that the next growth naturally fills. No rewind means duplication is impossible by construction.
  • Only a collapse past the viewport top re-anchors (compaction, clears): nothing sensible could be shown otherwise, and the content has changed so drastically there that the repainted span is not recognizable as a duplicate.
  • Above-viewport length changes repaint the visible window instead of painting the shifted content through the screen (which pushed already-committed rows into scrollback again). The anchor only advances when content outgrows the pinned window.
  • Deleted-tail changes within a pinned viewport repaint at the pinned anchor instead of falling back to a destructive full render.

Pure appends keep flowing through the screen into scrollback unchanged, and equal-length above-viewport changes keep the bounded clamped diff, so following a long stream by scrolling back still works. No ESC[2J/ESC[3J is emitted on any of these paths — scrollback and the user's scroll position stay intact, and the scroll-yank fix from #1315 does not regress.

Review fixes

Two correctness findings from review are fixed on top:

  • Anchor advances now commit the skipped rows. When one frame combined an above-viewport change with growth past the pinned window, the in-place repaint advanced the anchor without scrolling, so the skipped rows never entered scrollback and were lost. The repaint now paints from the old anchor and scrolls the skipped rows out with fresh content, committing each exactly once.
  • Hardware cursor stays in sync when the marker is off-window. A cursor marker above a pinned viewport (tall editor after a deep shrink) used to record hardwareCursorRow on an invisible logical row while the real cursor clamped at the screen top, desyncing every later differential move. The cursor is now hidden when the marker is outside the visible window and the bookkeeping stays on the real cursor row.

e2e rendering-bug ledger

packages/pi-tui/e2e/ now keeps one xterm-emulated end-to-end repro per production rendering bug (blank screen on collapse, scroll-position yank, full-redraw residue, oscillation duplication, anchor-advance row loss, cursor desync), asserting the renderer invariants documented in e2e/README.md: monotonic anchor, exactly-once scrollback commit, cursor bookkeeping sync. The cases run as part of pnpm --filter @moonshot-ai/pi-tui test.

Reproduced deterministically with the xterm-headless harness (each e2e case fails on the buggy revision and passes with this change) and verified in a live terminal with PI_DEBUG_REDRAW=1 under a long streaming workload.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

Rewinding the viewport anchor repaints rows that the terminal
scrollback already holds, and the next scroll commits them again —
every rewind duplicated its span. Two paths triggered it during
streaming oscillation (content shrinking then growing back): the
shrink re-anchor rewound immediately, and the clamped differential
path then painted the shifted content through the screen.

Rework the shrink/shift handling around a shared in-place viewport
repaint that never scrolls, with the anchor treated as the scrollback
high-water mark that must never move backward:

- Partial shrinks keep the anchor pinned: the content bottom hovers
  above a bounded blank gap that the next growth naturally fills. No
  rewind means duplication is impossible by construction.
- Only a collapse past the viewport top (compaction, clears) rewinds,
  as nothing sensible could be shown otherwise; the content has
  changed so drastically there that the repainted span is not
  recognizable as a duplicate.
- Above-viewport length changes repaint the visible window in place
  instead of painting through: nothing scrolls, scrollback keeps the
  stale old version, and the anchor only advances when the content
  outgrows the pinned window.
- Deleted-tail changes within a pinned viewport repaint at the pinned
  anchor instead of falling back to a destructive full render.

Pure appends keep flowing through the screen into scrollback, and
equal-length above-viewport changes keep the bounded clamped diff.
@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: de3fc5a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@moonshot-ai/kimi-code Patch
@moonshot-ai/pi-tui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@de3fc5a
npx https://pkg.pr.new/@moonshot-ai/kimi-code@de3fc5a

commit: de3fc5a

@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: 7b634444b5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/pi-tui/src/tui.ts
…isibility

Address two review findings on the pinned-anchor rendering:

- An anchor advance (growth past the pinned viewport combined with an
  above-viewport change) repainted the screen in place without
  scrolling, so the rows between the old and new anchor were never
  committed to scrollback and vanished. repaintViewport now paints from
  the old anchor and lets the paint loop scroll the skipped rows out,
  committing each exactly once with fresh content.

- positionHardwareCursor recorded hardwareCursorRow on a logical row
  outside the visible window when the cursor marker sat above a pinned
  viewport (tall editor after a deep shrink), desyncing every later
  differential move. It now hides the cursor and keeps the bookkeeping
  on the real cursor row when the marker is not visible.

Also add an e2e rendering-bug ledger (packages/pi-tui/e2e): one
xterm-emulated repro per production rendering bug, asserting the
renderer invariants (monotonic anchor, exactly-once commit, cursor
bookkeeping sync). The two findings above are case05/case06.
@liruifengv

Copy link
Copy Markdown
Collaborator Author

@codex

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 0d66073aec

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

pi-tui's tests (unit + e2e) run on node:test, which the root vitest
run silently skips, so CI never executed them. Add a test-pi-tui job
that runs the package's test script.
@liruifengv liruifengv merged commit 68ad686 into main Jul 3, 2026
10 checks passed
@liruifengv liruifengv deleted the fix/pi-tui-scrollback-dup branch July 3, 2026 15:49
@github-actions github-actions Bot mentioned this pull request Jul 3, 2026
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.

1 participant