diff --git a/.changeset/tui-scroll-anchoring.md b/.changeset/tui-scroll-anchoring.md new file mode 100644 index 0000000000..11161e2544 --- /dev/null +++ b/.changeset/tui-scroll-anchoring.md @@ -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. diff --git a/packages/pi-tui/src/components/scroll-view.ts b/packages/pi-tui/src/components/scroll-view.ts index f279a862da..568e4db832 100644 --- a/packages/pi-tui/src/components/scroll-view.ts +++ b/packages/pi-tui/src/components/scroll-view.ts @@ -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); + 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; diff --git a/packages/pi-tui/src/tui-alt-screen.ts b/packages/pi-tui/src/tui-alt-screen.ts index 4acaf77c32..a760debd41 100644 --- a/packages/pi-tui/src/tui-alt-screen.ts +++ b/packages/pi-tui/src/tui-alt-screen.ts @@ -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 { @@ -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; } } @@ -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(); } private parseSgrMouseEvent(data: string): SgrMouseEvent | undefined { diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 6ca99c596b..7eb1191d42 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -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; diff --git a/packages/pi-tui/test/layout.test.ts b/packages/pi-tui/test/layout.test.ts index 8569542719..46e8916144 100644 --- a/packages/pi-tui/test/layout.test.ts +++ b/packages/pi-tui/test/layout.test.ts @@ -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";