Skip to content
Merged
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
8 changes: 4 additions & 4 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.
3 changes: 0 additions & 3 deletions packages/tui/src/tui/engine/components/scroll-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
72 changes: 72 additions & 0 deletions packages/tui/test/unit/tui-scrollbar-interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {} };
Expand Down Expand Up @@ -345,10 +398,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");
});
});
Loading