From c0b8c51632aff2a81b2e61a6c0538cd1bb380a56 Mon Sep 17 00:00:00 2001 From: muyu Date: Thu, 24 Sep 2026 10:31:35 +0800 Subject: [PATCH 1/2] fix(tui): preserve detached scrolling across resize --- .../tui/src/tui/engine/LOCAL_CHANGES.json | 8 ++++---- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 7 +++++++ .../src/tui/engine/components/scroll-view.ts | 3 --- .../unit/tui-scrollbar-interaction.test.ts | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 690550ab..5bbc3cb6 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -49,10 +49,10 @@ }, { "path": "components/scroll-view.ts", - "currentSha256": "6f82fb1f426fdf81ca7058e6a1ea4a94c9ddd8f0e15d69c9020f9c66ca5411dd", - "changeIds": ["L035"], - "reason": "Allow hosts to reserve a wider always-visible scrollbar gutter while retaining at least one content column.", - "behaviorImpact": "The fullscreen transcript reserves three columns; other scroll views retain the default one-column gutter." + "currentSha256": "a2d9d5a19b40afd9d5a83f611890b592afc9764d916b24ec6090bbd5b95e21ea", + "changeIds": ["L035", "L043"], + "reason": "Allow hosts to reserve a wider always-visible scrollbar gutter while retaining at least one content column. Preserve follow state when layout changes clamp the scroll position.", + "behaviorImpact": "The fullscreen transcript reserves three columns; other scroll views retain the default one-column gutter. A detached viewport stays detached across layout changes until scrolling or an explicit follow request restores following." }, { "path": "components/settings-list.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index b240a9f3..ea15f733 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -177,3 +177,10 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Minimal difference: expose a generic history-text decoder and capture/restore the existing undo extension state alongside the history draft. Plugin parsing and identity ownership stay in the product Editor. - Evidence: `tui-plugin-mentions.test.ts` covers repeated history navigation, identical display labels with different IDs, working-draft restoration, atomic deletion and undo. - Removal condition: the selected Pi baseline supports durable history decoding and draft extension state. + +## L043: Preserve detached scrolling across layout changes + +- Product contract: a detached fullscreen transcript remains detached when the viewport grows or content shrinks. +- Minimal difference: `ScrollView.updateLayout` clamps the scroll position without changing follow state. Explicit scrolling and follow requests retain their existing behavior. +- Evidence: `tui-scrollbar-interaction.test.ts` covers wheel detachment, viewport growth, subsequent output and re-arming with End. +- Removal condition: the selected Pi baseline preserves follow state through layout clamping. diff --git a/packages/tui/src/tui/engine/components/scroll-view.ts b/packages/tui/src/tui/engine/components/scroll-view.ts index 41f8444a..dfa282dd 100644 --- a/packages/tui/src/tui/engine/components/scroll-view.ts +++ b/packages/tui/src/tui/engine/components/scroll-view.ts @@ -192,9 +192,6 @@ export class ScrollView extends Container { if (this.followingEnd) this.currentScrollTop = maxScrollTop; else 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; - } if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar(); } diff --git a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts index 06c8394f..413f0f3c 100644 --- a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts +++ b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts @@ -345,10 +345,29 @@ describe("Scrollbar interaction boundaries", () => { await terminal.waitForRender(); expect(viewportText(terminal)).toContain("line-60"); + terminal.sendInput("\x1b[<64;10;4M"); + await terminal.waitForRender(); + expect(tui.isFollowingOutput).toBe(false); + + terminal.resize(40, 45); + await terminal.waitForRender(); + const resizedTop = tui.viewportTop; + expect(tui.isFollowingOutput).toBe(false); + content.appendLine("line-61"); layout.followBottom(); tui.requestRender(); await terminal.waitForRender(); + expect(tui.viewportTop).toBe(resizedTop); + + terminal.sendInput("\x1b[F"); + await terminal.waitForRender(); expect(viewportText(terminal)).toContain("line-61"); + + content.appendLine("line-62"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-62"); }); }); From 857df53b813920a0da85e7cbed0f6837bd90c5b2 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Thu, 24 Sep 2026 14:22:09 +0800 Subject: [PATCH 2/2] test(tui): cover detached scrolling across content and footer shrink --- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 2 +- .../unit/tui-scrollbar-interaction.test.ts | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index ea15f733..2a59f78e 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -182,5 +182,5 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Product contract: a detached fullscreen transcript remains detached when the viewport grows or content shrinks. - Minimal difference: `ScrollView.updateLayout` clamps the scroll position without changing follow state. Explicit scrolling and follow requests retain their existing behavior. -- Evidence: `tui-scrollbar-interaction.test.ts` covers wheel detachment, viewport growth, subsequent output and re-arming with End. +- Evidence: `tui-scrollbar-interaction.test.ts` covers wheel detachment, viewport growth, footer/content shrink, temporarily fitting all content, subsequent output and re-arming with End. - Removal condition: the selected Pi baseline preserves follow state through layout clamping. diff --git a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts index 413f0f3c..63a56857 100644 --- a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts +++ b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts @@ -307,6 +307,59 @@ describe("Scrollbar interaction boundaries", () => { expect(viewportText(terminal)).toContain("composer"); }); + it.each(["footer shrink", "content shrink", "all content fitting"] as const)( + "keeps streaming detached after %s clamps the viewport to the end", + async (change) => { + const terminal = new VirtualTerminal(40, 15); + const empty = { render: () => [], invalidate() {} }; + let lines = Array.from({ length: 60 }, (_, index) => `answer-${index}`); + let footerRows = 4; + const layout = new TuiChatLayout(terminal, { + surface: () => "conversation", + transcript: { ...empty, render: () => [...lines] }, + welcome: empty, + interaction: { ...empty, isActive: () => false }, + activity: { ...empty, render: () => Array(footerRows).fill("activity") }, + followUp: empty, + composer: { ...empty, render: () => ["composer"] }, + status: empty, + }); + const tui = new TuiAltScreen(terminal); + screens.push(tui); + tui.setLayoutRoot(layout.fullscreenLayoutRoot); + tui.start(); + await terminal.waitForRender(); + terminal.sendInput("\x1b[<64;10;4M"); + await terminal.waitForRender(); + expect(tui.isFollowingOutput).toBe(false); + + if (change === "footer shrink") footerRows = 0; + else lines = lines.slice(0, change === "content shrink" ? 50 : 0); + tui.renderNow(); + await terminal.flush(); + const anchor = tui.viewportTop; + expect(tui.isFollowingOutput).toBe(false); + + for (let chunk = 0; chunk < 20; chunk++) { + lines.push(`streamed-${chunk}`); + layout.followBottom(); + tui.renderNow(); + await terminal.flush(); + expect(tui.viewportTop).toBe(anchor); + expect(tui.isFollowingOutput).toBe(false); + } + terminal.sendInput("\x1b[F"); + await terminal.waitForRender(); + expect(tui.isFollowingOutput).toBe(true); + expect(viewportText(terminal)).toContain("streamed-19"); + lines.push("resumed-stream"); + layout.followBottom(); + tui.renderNow(); + await terminal.flush(); + expect(viewportText(terminal)).toContain("resumed-stream"); + }, + ); + it("preserves a detached transcript position until follow-tail is explicitly re-armed", async () => { const terminal = new VirtualTerminal(40, 15); const empty = { render: () => [], invalidate() {} };