From 8c727937854b4f435df56809b022ae1015a17c9e Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Mon, 21 Sep 2026 17:16:06 +0200 Subject: [PATCH] fix(tui): prevent jump-to-end label from shifting when scrollbar hides (#9842) - center the label independently of scrollbar visibility - clip drawing and click bounds before the scrollbar - fixes #9136 --- packages/tui/src/tui-alt-screen.ts | 9 +++-- packages/tui/test/tui-alt-screen.test.ts | 47 +++++++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 1b3d2614d..cc9a04f43 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -1630,11 +1630,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const row = clip.y + clip.height - 1; if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen; const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined; - const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x); - const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, ""); + const label = truncateToWidth(this.scrollToEndIndicator(), clip.width, ""); + const labelWidth = visibleWidth(label); + const column = clip.x + Math.floor((clip.width - labelWidth) / 2); + const rightEdge = scrollbarColumn ?? clip.x + clip.width; + const availableWidth = Math.max(0, rightEdge - column); + const text = truncateToWidth(label, availableWidth, ""); const textWidth = visibleWidth(text); if (textWidth === 0) return screen; - const column = clip.x + Math.floor((availableWidth - textWidth) / 2); const result = [...screen]; result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width); this.scrollToEndIndicatorRect = { row, column, width: textWidth }; diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index fa007d2ad..e79169127 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -138,7 +138,51 @@ describe("TuiAltScreen", () => { tui.stop(); }); - it("leaves the scrollbar clickable when the jump-to-end indicator spans the transcript", async () => { + it("keeps the jump-to-end indicator centered as the auto scrollbar hides and reappears", async () => { + // Regression test for #9136: auto scrollbar visibility must not move the indicator. + const terminal = new VirtualTerminal(80, 6); + const label = " ↓ Jump to latest message · End "; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + scrollToEndIndicator: () => label, + }); + const transcript = new ScrollView( + new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), + { follow: "end", primary: true, scrollbar: "auto", scrollbarHideDelayMs: 0 }, + ); + tui.setLayoutRoot(transcript); + tui.start(); + try { + await terminal.waitForRender(); + + // Scrolling over the track keeps the scrollbar visible until the pointer leaves. + terminal.sendInput("\x1b[<64;80;1M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isScrollbarVisible, true); + assert.strictEqual(transcript.isFollowingEnd, false); + const scrollTop = transcript.scrollTop; + const visibleColumn = terminal.getViewport()[5].indexOf(label); + + // Leaving the track lets the auto-hide timer expire without changing the content. + terminal.sendInput("\x1b[<35;79;1M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isScrollbarVisible, false); + assert.strictEqual(transcript.scrollTop, scrollTop); + const hiddenColumn = terminal.getViewport()[5].indexOf(label); + + terminal.sendInput("\x1b[<35;80;1M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isScrollbarVisible, true); + assert.strictEqual(transcript.scrollTop, scrollTop); + const revealedColumn = terminal.getViewport()[5].indexOf(label); + + assert.deepStrictEqual([visibleColumn, hiddenColumn, revealedColumn], [24, 24, 24]); + } finally { + tui.stop(); + } + }); + + it("leaves the scrollbar visible and clickable when the jump-to-end indicator spans the transcript", async () => { + // Regression coverage for #9136: centering must not paint or capture clicks over the scrollbar. const terminal = new VirtualTerminal(30, 6); const tui = new TuiAltScreen(terminal, undefined, undefined, { scrollToEndIndicator: () => "↓".repeat(30), @@ -161,6 +205,7 @@ describe("TuiAltScreen", () => { assert.strictEqual(transcript.isFollowingEnd, false); // The indicator must not intercept a press on the scrollbar's last column. + assert.strictEqual(terminal.getViewport()[3], `${"↓".repeat(29)}┃`); terminal.sendInput("\x1b[<0;30;4M"); terminal.sendInput("\x1b[<0;30;4m"); await terminal.waitForRender();