Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tui-scroll-anchoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Fix janky mouse-wheel scrolling during streaming: render scroll input immediately instead of throttling it, and keep the transcript viewport anchored to the same content when it shrinks mid-turn (fold/trim) so the view no longer snaps to the top.
16 changes: 14 additions & 2 deletions packages/pi-tui/src/components/scroll-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,24 @@ export class ScrollView extends Container {
}

updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {
const previousContentHeight = this.contentHeight;
this.contentHeight = Math.max(0, Math.floor(contentHeight));
this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight));
this.requestRenderCallback = requestRender;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
if (this.followingEnd) this.currentScrollTop = maxScrollTop;
else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));
if (this.followingEnd) {
this.currentScrollTop = maxScrollTop;
} else {
// Keep the viewport anchored to the same content when the content
// shrinks while the user is scrolled up (e.g. a transcript folds older
// steps or trims old turns mid-stream). Content removed above the
// viewport shifts the remaining lines up, so the scroll offset must
// shrink by the same amount — otherwise the clamp below snaps the view
// to the top.
const shrink = previousContentHeight - this.contentHeight;
if (shrink > 0) this.currentScrollTop = Math.max(0, this.currentScrollTop - shrink);
Comment on lines +201 to +202

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not shift for shrinkage below the viewport

When a reader has scrolled into an older turn, mergeCurrentTurnSteps() can fold the active turn at the bottom, entirely below the viewport. updateLayout() receives only the total height, so subtracting every decrease from currentScrollTop treats that bottom-only shrink as rows removed above and jumps the reader upward by shrink; the same problem occurs whenever a component below the viewport reflows shorter. Preserve the offset unless removal is known to be above a tracked anchor, or pass positional anchor information into this update.

Useful? React with 👍 / 👎.

this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));
}
if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false;
if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) {
this.followingEnd = true;
Expand Down
10 changes: 5 additions & 5 deletions packages/pi-tui/src/tui-alt-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,17 +390,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {

scrollBy(lines: number): void {
this.getPrimaryScrollView().scrollBy(lines);
this.requestRender();
this.requestImmediateRender();
}

scrollToTop(): void {
this.getPrimaryScrollView().scrollToStart();
this.requestRender();
this.requestImmediateRender();
}

scrollToBottom(): void {
this.getPrimaryScrollView().scrollToEnd();
this.requestRender();
this.requestImmediateRender();
}

private scrollToPrompt(direction: -1 | 1): void {
Expand All @@ -412,7 +412,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
for (let row = scrollView.scrollTop + direction; row >= 0 && row < lines.length; row += direction) {
if (!OSC133_PROMPT_START.test(lines[row] ?? "")) continue;
scrollView.scrollTo(row);
this.requestRender();
this.requestImmediateRender();
return;
}
}
Expand Down Expand Up @@ -678,7 +678,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
const primary = this.getPrimaryScrollView();
if (remaining !== 0 && !seen.has(primary)) primary.scrollBy(remaining);
this.updateScrollbarHover(event.x, event.y);
this.requestRender();
this.requestImmediateRender();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document and guard the new vendored divergence

This changes vendored scrolling behavior, but the package's exhaustive local-divergence list was not updated and the added test covers only height anchoring, not the immediate wheel-render path here. Existing alt-screen tests merely wait for an eventual render, so reverting these calls to throttled requestRender() would still pass; add the divergence entry and a timing/preemption test so the next re-vendor cannot silently drop the fix.

AGENTS.md reference: packages/pi-tui/AGENTS.md:L3-L7

Useful? React with 👍 / 👎.

}

private parseSgrMouseEvent(data: string): SgrMouseEvent | undefined {
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-tui/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ export abstract class TuiBase extends Container implements TUI {
process.nextTick(() => this.scheduleRender());
}

private requestImmediateRender(): void {
protected requestImmediateRender(): void {
this.cancelRenderTimer();
this.renderRequested = true;
if (this.immediateRenderScheduled) return;
Expand Down
19 changes: 19 additions & 0 deletions packages/pi-tui/test/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@ describe("viewport layout", () => {
assert.strictEqual(scrollView.isFollowingEnd, true);
});

it("keeps the viewport anchored when content shrinks while scrolled up", () => {
const content = new Text("1\n2\n3\n4\n5\n6\n7\n8\n9\n10", 0, 0);
const scrollView = new ScrollView(content, { follow: "end", primary: true });
renderLayoutFrame(scrollView, 10, 3, () => {});
assert.strictEqual(scrollView.scrollTop, 7);

// Scroll up to read earlier content, detaching from follow-end.
scrollView.scrollBy(-4);
assert.strictEqual(scrollView.scrollTop, 3);
assert.strictEqual(scrollView.isFollowingEnd, false);

// Content above the viewport is removed (fold/trim). The same lines the
// user was reading must stay in view instead of snapping to the bottom.
content.setText("5\n6\n7\n8\n9\n10");
renderLayoutFrame(scrollView, 10, 3, () => {});
assert.strictEqual(scrollView.scrollTop, 0);
assert.strictEqual(scrollView.isFollowingEnd, false);
});

it("renders a transient proportional scrollbar without replacing cell content", async () => {
const sourceLines = ["abcd界", "abcde2", "abcde3", "abcde4", "abcde5", "abcde6", "abcde7", "abcde8"];
const contentBackground = "\x1b[42m";
Expand Down