Skip to content

Commit 7a47ab4

Browse files
committed
fix(tui): stop a size pass from snapping one detent to the tail
OpenTUI re-engages bottom sticky at one row above the tail. Sticky now stays armed only while scrollTop is on the tail, so a same-height size pass leaves that detent pinned. Growth while the tail is followed still sticks. CL-10642
1 parent a43ed39 commit 7a47ab4

6 files changed

Lines changed: 64 additions & 13 deletions

File tree

‎src/tui/allow-once-reprompt.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,6 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
485485
const list = defined(shell.overlayList, "slash list");
486486
expect(list.select.isDestroyed).toBe(false);
487487
expect(list.viewport.parent).toBe(shell.overlayView.body);
488-
expect(list.select.isDestroyed).toBe(false);
489488

490489
let resolved: unknown;
491490
emitter.emit("permission.gate", {

‎src/tui/shell.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,37 @@ describe("createAppShell", () => {
125125
);
126126
});
127127

128+
test("one wheel detent survives a same-height content size pass", async () => {
129+
await withTestRenderer(
130+
async (h) => {
131+
const shell = createAppShell(h.renderer, {
132+
terminal: { columns: 80, rows: 24 },
133+
wireKeys: false,
134+
});
135+
try {
136+
for (let i = 0; i < 50; i++) {
137+
appendTranscript(shell, `seed-${i}`);
138+
}
139+
await h.renderOnce();
140+
const max = Math.max(
141+
0,
142+
shell.transcript.scrollHeight - shell.transcript.height,
143+
);
144+
expect(max).toBeGreaterThan(1);
145+
const scrollHeight = shell.transcript.scrollHeight;
146+
shell.transcript.scrollTop = max - 1;
147+
shell.transcript.content.onSizeChange?.();
148+
expect(shell.transcript.scrollHeight).toBe(scrollHeight);
149+
expect(shell.transcript.scrollTop).toBe(max - 1);
150+
expect(isTranscriptFollowing(shell)).toBe(false);
151+
} finally {
152+
shell.dispose();
153+
}
154+
},
155+
{ width: 80, height: 24 },
156+
);
157+
});
158+
128159
test("one wheel detent up from a followed tail stays off the bottom", async () => {
129160
await withTestRenderer(
130161
async (h) => {

‎src/tui/shell/chrome.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,9 +1065,7 @@ export function syncTranscriptSpacer(shell: AppShell): void {
10651065
transcriptJustify.set(shell, justify);
10661066
}
10671067

1068-
/** Keep the newest row in view while the tail is followed. A child that has
1069-
* not been laid out reports `y === 0`; scrolling that into view yanks the
1070-
* viewport off the tail. */
1068+
/** Keep the newest row in view while the tail is followed. */
10711069
export function followTranscriptTail(shell: AppShell): void {
10721070
if (isLanding(shell) || !isTranscriptFollowing(shell)) return;
10731071
const last = shell.transcript.getChildren().at(-1);
@@ -1393,7 +1391,7 @@ export function replaceStreamRowAt(
13931391
export function repaintTranscriptWindow(shell: AppShell): void {
13941392
clearLandingMark(shell);
13951393
shell.agentVoices = new Set(agentVoicesIn(shell.streamLog));
1396-
// The bottom-anchor spacer (index 0) stays; the eviction notice (if any)
1394+
// Index 0 is the zero-height spacer and stays; the eviction notice (if any)
13971395
// and every row get torn down and rebuilt from the log.
13981396
for (const child of shell.transcript.getChildren().slice(1)) {
13991397
shell.transcript.remove(child);

‎src/tui/shell/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { flushStreamRowUpdates } from "../runtime-bridge.js";
3434
import {
3535
type AppShell,
3636
type AppShellOptions,
37+
bindTranscriptTailSticky,
3738
EMPTY_PRIMARY_BINDINGS,
3839
flashTimers,
3940
initShellInternals,
@@ -213,6 +214,7 @@ export function createAppShell(
213214
// row restating it.
214215
transcript.verticalScrollBar.visible = false;
215216
transcript.horizontalScrollBar.visible = false;
217+
bindTranscriptTailSticky(transcript);
216218
// Markdown links live on nodes we do not own; this bubbling handler arms
217219
// them (armed rows stop propagation, so a click opens exactly once).
218220
armMarkdownLinks(transcript, ctx);

‎src/tui/shell/internals.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -498,16 +498,41 @@ export type PrimaryOverlayKind =
498498
| "mcp"
499499
| "plugin_credentials";
500500

501-
/** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED).
502-
* The bottom is `scrollTop >= max` only. `max - 1` is one wheel detent up;
503-
* treating it as followed makes the next frame's `scrollChildIntoView` yank
504-
* the tail back. */
501+
/** True when the viewport is on the tail (`scrollTop >= max`). */
505502
export function isTranscriptFollowing(shell: AppShell): boolean {
506503
const { transcript } = shell;
507504
const max = Math.max(0, transcript.scrollHeight - transcript.height);
508505
return transcript.scrollTop >= max;
509506
}
510507

508+
/** Bottom sticky stays armed only on the tail, so one detent up stays pinned. */
509+
export function bindTranscriptTailSticky(
510+
transcript: ScrollBoxRenderable,
511+
): void {
512+
const desc = Object.getOwnPropertyDescriptor(
513+
Object.getPrototypeOf(transcript),
514+
"scrollTop",
515+
);
516+
const get = desc?.get;
517+
const set = desc?.set;
518+
if (get === undefined || set === undefined) {
519+
throw new Error("ScrollBox scrollTop accessor is missing");
520+
}
521+
const sync = (): void => {
522+
const max = Math.max(0, transcript.scrollHeight - transcript.height);
523+
transcript.stickyScroll = transcript.scrollTop >= max;
524+
};
525+
Object.defineProperty(transcript, "scrollTop", {
526+
configurable: true,
527+
enumerable: true,
528+
get: () => get.call(transcript),
529+
set: (value: number) => {
530+
set.call(transcript, value);
531+
sync();
532+
},
533+
});
534+
}
535+
511536
/** Sticky-scroll mode label (surfaced on the notice row only when PINNED). */
512537
export function stickyMode(shell: AppShell): "FOLLOW" | "PINNED" {
513538
return isTranscriptFollowing(shell) ? "FOLLOW" : "PINNED";

‎src/tui/shell/overlay-list.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,6 @@ export function createOverlayList(
223223
const placed = viewport.content.onSizeChange;
224224
let revealing = false;
225225

226-
// Absolute marker at the active row. scrollChildIntoView on the whole select
227-
// is a no-op when the select is taller than the viewport (both edges outside).
228226
const anchor = new BoxRenderable(ctx, {
229227
id: "shell-overlay-active-anchor",
230228
position: "absolute",
@@ -253,8 +251,6 @@ export function createOverlayList(
253251
if (count > 0 && anchor.y !== 0 && viewport.viewport.y !== 0) {
254252
viewport.scrollChildIntoView(anchor.id);
255253
}
256-
// Applied last: scrollTop clamps until the bar has a content size, and
257-
// that clamp rewrites translateY. The viewport paints this translation.
258254
viewport.content.translateY = -y;
259255
} finally {
260256
revealing = false;

0 commit comments

Comments
 (0)