Skip to content

Commit 0656ac3

Browse files
Merge pull request #1461 from corbitsdev/cl-10642-tui-adopt-scrollbox-full-api-scrollbar-delete-scroll-hacks
feat(tui): scroll the transcript and overlay through ScrollBox
2 parents 0a2de14 + 7a47ab4 commit 0656ac3

10 files changed

Lines changed: 264 additions & 52 deletions

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
484484
openSlash(shell);
485485
const list = defined(shell.overlayList, "slash list");
486486
expect(list.select.isDestroyed).toBe(false);
487-
expect(list.select.parent).toBe(shell.overlayView.body);
487+
expect(list.viewport.parent).toBe(shell.overlayView.body);
488488

489489
let resolved: unknown;
490490
emitter.emit("permission.gate", {
@@ -501,8 +501,8 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
501501
expect(shell.overlayKind).toBe("help");
502502
expect(shell.overlayList).toBe(list);
503503
expect(list.select.isDestroyed).toBe(false);
504-
expect(list.select.parent).toBe(shell.overlayView.body);
505-
expect(shell.overlayView.body.getChildren()).toContain(list.select);
504+
expect(list.viewport.parent).toBe(shell.overlayView.body);
505+
expect(shell.overlayView.body.getChildren()).toContain(list.viewport);
506506
});
507507
});
508508

‎src/tui/decision-truncation.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,12 @@ const hintRequest: PermissionRequest = {
4141
function bodySelect(
4242
view: ReturnType<typeof createOverlayView>,
4343
): SelectRenderable {
44+
// The list lives in the host ScrollBox, not as a direct body child.
4445
const found = view.body
4546
.getChildren()
47+
.flatMap((row) =>
48+
row instanceof SelectRenderable ? [row] : row.getChildren(),
49+
)
4650
.find((row) => row instanceof SelectRenderable);
4751
if (!(found instanceof SelectRenderable))
4852
throw new Error("expected the overlay list");

‎src/tui/overlay-paint.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,18 @@ async function paintOverlay(
8383
/**
8484
* Every interior row must be blank or exactly one expected overlay row; two
8585
* renderables sharing cells produces a hybrid string that matches nothing.
86+
* A native ScrollBar thumb occupies the last column when the list overflows;
87+
* that glyph is not a second copy of the row.
8688
*/
8789
function expectCleanInterior(
8890
interior: readonly string[],
8991
expected: readonly string[],
9092
): void {
9193
const allowed = new Set(expected.map((e) => e.trimEnd()));
9294
for (const row of interior) {
93-
if (row.trim().length === 0) continue;
94-
expect(allowed.has(row)).toBe(true);
95+
const withoutBar = row.replace(/[▀▄█▌▐░▒▓]+$/u, "").trimEnd();
96+
if (withoutBar.length === 0) continue;
97+
expect(allowed.has(withoutBar)).toBe(true);
9598
}
9699
}
97100

@@ -125,7 +128,8 @@ describe("overlay host never shares cells with the prompt border", () => {
125128
expectCleanInterior(interior.slice(1), expected);
126129

127130
// The selected row must be intact, not overwritten by the model label.
128-
expect(interior).toContain(` ${ITEMS[0]}`);
131+
// A ScrollBar thumb may sit on the same line, so match the label prefix.
132+
expect(interior.some((row) => row.startsWith(` ${ITEMS[0]}`))).toBe(true);
129133
for (const row of interior) {
130134
expect(row.includes(MODEL_LABEL)).toBe(false);
131135
expect(row.includes("alice")).toBe(false);

‎src/tui/overlay-view.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ function bodyRows(view: ReturnType<typeof createOverlayView>): string[] {
3333
function bodySelect(
3434
view: ReturnType<typeof createOverlayView>,
3535
): SelectRenderable {
36+
// The list lives in the host ScrollBox, not as a direct body child.
3637
const found = view.body
3738
.getChildren()
39+
.flatMap((row) =>
40+
row instanceof SelectRenderable ? [row] : row.getChildren(),
41+
)
3842
.find((row) => row instanceof SelectRenderable);
3943
if (!(found instanceof SelectRenderable))
4044
throw new Error("expected the overlay list");

‎src/tui/overlay-view.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,8 @@ export function createOverlayView(ctx: RenderContext) {
373373
name: line,
374374
description: "",
375375
}));
376-
body.add(list.select);
376+
body.add(list.viewport);
377+
list.scrollActiveIntoView();
377378
}
378379

379380
/** Rule + two-line description zone, painted when `describe` is set. */
@@ -414,11 +415,11 @@ export function createOverlayView(ctx: RenderContext) {
414415
}
415416

416417
/**
417-
* Detach the select before `clearBody` destroys the body's children — the
418-
* list owns it across paints.
418+
* Detach the list viewport before `clearBody` destroys the body's children —
419+
* the list owns the select inside that ScrollBox across paints.
419420
*/
420421
function detachList(list: OverlayList): void {
421-
if (list.select.parent === body) body.remove(list.select);
422+
if (list.viewport.parent) body.remove(list.viewport);
422423
}
423424

424425
function paintList(
@@ -455,10 +456,11 @@ export function createOverlayView(ctx: RenderContext) {
455456
});
456457
// An empty list renders nothing: the renderable would claim a background
457458
// row the chooser did not reserve.
458-
if (presentation.items.length > 0) body.add(list.select);
459+
if (presentation.items.length > 0) body.add(list.viewport);
459460
else addOverlayRow(` ${OVERLAY_EMPTY_STATE}`, UI.textDim);
460461
paintAnswerRow(presentation.answer, contentWidth);
461462
paintDescriptionZone(presentation.describe, contentWidth);
463+
list.scrollActiveIntoView();
462464
}
463465

464466
function paintTitle(

‎src/tui/shell.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,70 @@ 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+
159+
test("one wheel detent up from a followed tail stays off the bottom", async () => {
160+
await withTestRenderer(
161+
async (h) => {
162+
const shell = createAppShell(h.renderer, {
163+
terminal: { columns: 80, rows: 24 },
164+
wireKeys: false,
165+
});
166+
try {
167+
for (let i = 0; i < 50; i++) {
168+
appendTranscript(shell, `seed-${i}`);
169+
}
170+
await h.renderOnce();
171+
const max = Math.max(
172+
0,
173+
shell.transcript.scrollHeight - shell.transcript.height,
174+
);
175+
expect(max).toBeGreaterThan(1);
176+
expect(isTranscriptFollowing(shell)).toBe(true);
177+
178+
// One detent, not a multi-tick fling. The next frame must not
179+
// treat max - 1 as the tail and scroll it back.
180+
shell.transcript.scrollTop = max - 1;
181+
await h.renderOnce();
182+
expect(shell.transcript.scrollTop).toBe(max - 1);
183+
expect(isTranscriptFollowing(shell)).toBe(false);
184+
} finally {
185+
shell.dispose();
186+
}
187+
},
188+
{ width: 80, height: 24 },
189+
);
190+
});
191+
128192
test("the notice says pinned only while the tail is off screen", async () => {
129193
await withTestRenderer(
130194
async (h) => {

‎src/tui/shell/chrome.ts‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,23 +1047,33 @@ export function syncPromptRows(shell: AppShell): void {
10471047
}
10481048

10491049
/**
1050-
* Resize the leading filler to soak leftover viewport space (`scrollHeight`
1051-
* minus filler). Not at row-mutation time: mid-layout rows read short for a
1052-
* frame, so a filler grown then would bury one. The render-frame hook calls
1053-
* it after that pass.
1050+
* Pin a short transcript to the prompt. ScrollBox content is already at least
1051+
* the viewport tall (`minHeight: "100%"`); justify-end packs the rows against
1052+
* the bottom. The landing positions its own mark, so flex-end there would
1053+
* shove it twice. The index-0 spacer stays at height 0 — growing it was the
1054+
* old anchor.
10541055
*/
1056+
const transcriptJustify = new WeakMap<AppShell, "flex-start" | "flex-end">();
1057+
10551058
export function syncTranscriptSpacer(shell: AppShell): void {
10561059
const spacer = transcriptSpacers.get(shell);
10571060
if (spacer === undefined) return;
1058-
// The landing already bottom-anchors its mark via the above/below split; a
1059-
// filler would double-count and squeeze it.
1060-
if (isLanding(shell)) {
1061-
if (spacer.height !== 0) spacer.height = 0;
1062-
return;
1063-
}
1064-
const rowsHeight = Math.max(0, shell.transcript.scrollHeight - spacer.height);
1065-
const nextHeight = Math.max(0, shell.transcript.height - rowsHeight);
1066-
if (spacer.height !== nextHeight) spacer.height = nextHeight;
1061+
if (spacer.height !== 0) spacer.height = 0;
1062+
const justify = isLanding(shell) ? "flex-start" : "flex-end";
1063+
if (transcriptJustify.get(shell) === justify) return;
1064+
shell.transcript.contentOptions = { justifyContent: justify };
1065+
transcriptJustify.set(shell, justify);
1066+
}
1067+
1068+
/** Keep the newest row in view while the tail is followed. */
1069+
export function followTranscriptTail(shell: AppShell): void {
1070+
if (isLanding(shell) || !isTranscriptFollowing(shell)) return;
1071+
const last = shell.transcript.getChildren().at(-1);
1072+
if (last === undefined || last.id === "shell-transcript-spacer") return;
1073+
if (last.height <= 0) return;
1074+
const viewportY = shell.transcript.viewport.y;
1075+
if (last.y === 0 && viewportY !== 0) return;
1076+
shell.transcript.scrollChildIntoView(last.id);
10671077
}
10681078

10691079
export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout {
@@ -1381,7 +1391,7 @@ export function replaceStreamRowAt(
13811391
export function repaintTranscriptWindow(shell: AppShell): void {
13821392
clearLandingMark(shell);
13831393
shell.agentVoices = new Set(agentVoicesIn(shell.streamLog));
1384-
// 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)
13851395
// and every row get torn down and rebuilt from the log.
13861396
for (const child of shell.transcript.getChildren().slice(1)) {
13871397
shell.transcript.remove(child);

‎src/tui/shell/index.ts‎

Lines changed: 8 additions & 2 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,
@@ -66,6 +67,7 @@ import {
6667
syncNoticeAfterLayout,
6768
syncPromptRows,
6869
syncTranscriptSpacer,
70+
followTranscriptTail,
6971
} from "./chrome.js";
7072
import {
7173
clearPendingAttachments,
@@ -202,6 +204,7 @@ export function createAppShell(
202204
stickyScroll: true,
203205
stickyStart: "bottom",
204206
scrollY: true,
207+
viewportCulling: true,
205208
focusable: true,
206209
rootOptions: { backgroundColor: UI.ground },
207210
contentOptions: { backgroundColor: UI.ground },
@@ -211,12 +214,14 @@ export function createAppShell(
211214
// row restating it.
212215
transcript.verticalScrollBar.visible = false;
213216
transcript.horizontalScrollBar.visible = false;
217+
bindTranscriptTailSticky(transcript);
214218
// Markdown links live on nodes we do not own; this bubbling handler arms
215219
// them (armed rows stop propagation, so a click opens exactly once).
216220
armMarkdownLinks(transcript, ctx);
217221

218-
// Leading filler that bottom-anchors a short transcript; see
219-
// `syncTranscriptSpacer`. Zero height until the first sync call.
222+
// Index 0 stays a zero-height spacer so row offsets do not move. Short
223+
// transcripts bottom-align through the scroll content (see
224+
// `syncTranscriptSpacer`), not by growing this filler.
220225
const transcriptSpacer = new BoxRenderable(ctx, {
221226
id: "shell-transcript-spacer",
222227
width: "100%",
@@ -388,6 +393,7 @@ export function createAppShell(
388393
// After a natural render, not at mutation time: the row's box needs a
389394
// layout pass to size itself, and claiming the padding first starves it.
390395
syncTranscriptSpacer(shell);
396+
followTranscriptTail(shell);
391397
syncNoticeAfterLayout(shell);
392398
};
393399

‎src/tui/shell/internals.ts‎

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

501-
/** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED). */
501+
/** True when the viewport is on the tail (`scrollTop >= max`). */
502502
export function isTranscriptFollowing(shell: AppShell): boolean {
503503
const { transcript } = shell;
504504
const max = Math.max(0, transcript.scrollHeight - transcript.height);
505-
return transcript.scrollTop >= max - 1;
505+
return transcript.scrollTop >= max;
506+
}
507+
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+
});
506534
}
507535

508536
/** Sticky-scroll mode label (surfaced on the notice row only when PINNED). */
@@ -546,6 +574,8 @@ export interface OverlayListRange {
546574
* rebuilds on geometry change. */
547575
export interface OverlayList {
548576
readonly select: SelectRenderable;
577+
/** ScrollBox that clips the select and owns the host ScrollBar. */
578+
readonly viewport: ScrollBoxRenderable;
549579
readonly activeIndex: number;
550580
/** Item-row capacity reserved by layout (not the renderable's row height). */
551581
readonly height: number;
@@ -557,6 +587,8 @@ export interface OverlayList {
557587
jump(index: number): void;
558588
setCount(count: number): void;
559589
setHeight(items: number, rowsPerItem?: number): void;
590+
/** Move the host ScrollBox so the active row is inside the viewport. */
591+
scrollActiveIntoView(): void;
560592
visibleRange(): OverlayListRange;
561593
}
562594

@@ -723,11 +755,9 @@ interface ShellInternals {
723755

724756
export const internals = new WeakMap<AppShell, ShellInternals>();
725757

726-
/** Filler row sized to leftover viewport space so a short transcript
727-
* bottom-anchors against the prompt box; settles at zero once rows fill it.
728-
* A real child, not padding: `minHeight: "100%"` counts padding as viewport
729-
* height, so `scrollHeight - spacer.height` isolates the rows.
730-
* Index 0 is always the spacer. */
758+
/** Index-0 child of the transcript. Height stays 0: short logs bottom-align
759+
* via the scroll content's justify-end, not a filler resized every frame.
760+
* The node remains so row children keep a stable offset. */
731761
export const transcriptSpacers = new WeakMap<AppShell, BoxRenderable>();
732762

733763
/** True while the landing composition is still mounted. */

0 commit comments

Comments
 (0)