Skip to content

Commit 96c0b6f

Browse files
committed
fix(tui): keep one wheel detent up from the tail pinned
1 parent 7f1bc6e commit 96c0b6f

2 files changed

Lines changed: 38 additions & 2 deletions

File tree

‎src/tui/shell.test.ts‎

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

128+
test("one wheel detent up from a followed tail stays off the bottom", 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+
expect(isTranscriptFollowing(shell)).toBe(true);
146+
147+
// One detent, not a multi-tick fling. The next frame must not
148+
// treat max - 1 as the tail and scroll it back.
149+
shell.transcript.scrollTop = max - 1;
150+
await h.renderOnce();
151+
expect(shell.transcript.scrollTop).toBe(max - 1);
152+
expect(isTranscriptFollowing(shell)).toBe(false);
153+
} finally {
154+
shell.dispose();
155+
}
156+
},
157+
{ width: 80, height: 24 },
158+
);
159+
});
160+
128161
test("the notice says pinned only while the tail is off screen", async () => {
129162
await withTestRenderer(
130163
async (h) => {

‎src/tui/shell/internals.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,11 +498,14 @@ export type PrimaryOverlayKind =
498498
| "mcp"
499499
| "plugin_credentials";
500500

501-
/** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED). */
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. */
502505
export function isTranscriptFollowing(shell: AppShell): boolean {
503506
const { transcript } = shell;
504507
const max = Math.max(0, transcript.scrollHeight - transcript.height);
505-
return transcript.scrollTop >= max - 1;
508+
return transcript.scrollTop >= max;
506509
}
507510

508511
/** Sticky-scroll mode label (surfaced on the notice row only when PINNED). */

0 commit comments

Comments
 (0)