Skip to content

Commit f7b53ac

Browse files
committed
feat(tui): scroll the transcript and overlay through ScrollBox
1 parent 0a2de14 commit f7b53ac

6 files changed

Lines changed: 84 additions & 45 deletions

File tree

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

Lines changed: 2 additions & 2 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.select.parent).toBe(shell.overlayView.body.content);
488488

489489
let resolved: unknown;
490490
emitter.emit("permission.gate", {
@@ -501,7 +501,7 @@ 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);
504+
expect(list.select.parent).toBe(shell.overlayView.body.content);
505505
expect(shell.overlayView.body.getChildren()).toContain(list.select);
506506
});
507507
});

‎src/tui/overlay-view.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
BoxRenderable,
3+
ScrollBoxRenderable,
34
TextRenderable,
45
type RenderContext,
56
} from "@opentui/core";
@@ -316,13 +317,24 @@ export function createOverlayView(ctx: RenderContext) {
316317
content: " overlay",
317318
fg: UI.textDim,
318319
});
319-
const body = new BoxRenderable(ctx, {
320+
const body = new ScrollBoxRenderable(ctx, {
320321
id: "shell-overlay-body",
321322
width: "100%",
322323
flexGrow: 1,
323-
flexDirection: "column",
324-
backgroundColor: UI.ground,
324+
flexShrink: 1,
325+
// The host scrolls as one surface. Arrow keys stay on the list selection;
326+
// the scrollbox must not take focus and page itself.
327+
focusable: false,
328+
viewportCulling: true,
329+
scrollY: true,
330+
stickyScroll: false,
331+
rootOptions: { backgroundColor: UI.ground },
332+
contentOptions: { backgroundColor: UI.ground },
333+
viewportOptions: { backgroundColor: UI.ground },
325334
});
335+
body.horizontalScrollBar.visible = false;
336+
body.horizontalScrollBar.focusable = false;
337+
body.verticalScrollBar.focusable = false;
326338
host.add(title);
327339
host.add(body);
328340

@@ -374,6 +386,7 @@ export function createOverlayView(ctx: RenderContext) {
374386
description: "",
375387
}));
376388
body.add(list.select);
389+
revealList(list);
377390
}
378391

379392
/** Rule + two-line description zone, painted when `describe` is set. */
@@ -415,10 +428,17 @@ export function createOverlayView(ctx: RenderContext) {
415428

416429
/**
417430
* Detach the select before `clearBody` destroys the body's children — the
418-
* list owns it across paints.
431+
* list owns it across paints. ScrollBox.add parents into content, so the
432+
* select's parent is not the scrollbox itself.
419433
*/
420434
function detachList(list: OverlayList): void {
421-
if (list.select.parent === body) body.remove(list.select);
435+
if (list.select.parent) body.remove(list.select);
436+
}
437+
438+
/** Keep the live choice inside the host viewport after a paint or move. */
439+
function revealList(list: OverlayList): void {
440+
if (list.select.parent === null || list.select.height <= 0) return;
441+
body.scrollChildIntoView(list.select.id);
422442
}
423443

424444
function paintList(
@@ -459,6 +479,7 @@ export function createOverlayView(ctx: RenderContext) {
459479
else addOverlayRow(` ${OVERLAY_EMPTY_STATE}`, UI.textDim);
460480
paintAnswerRow(presentation.answer, contentWidth);
461481
paintDescriptionZone(presentation.describe, contentWidth);
482+
revealList(list);
462483
}
463484

464485
function paintTitle(

‎src/tui/shell/chrome.ts‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,23 +1047,35 @@ 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. A child that has
1069+
* not been laid out reports `y === 0`; scrolling that into view yanks the
1070+
* viewport off the tail. */
1071+
export function followTranscriptTail(shell: AppShell): void {
1072+
if (isLanding(shell) || !isTranscriptFollowing(shell)) return;
1073+
const last = shell.transcript.getChildren().at(-1);
1074+
if (last === undefined || last.id === "shell-transcript-spacer") return;
1075+
if (last.height <= 0) return;
1076+
const viewportY = shell.transcript.viewport.y;
1077+
if (last.y === 0 && viewportY !== 0) return;
1078+
shell.transcript.scrollChildIntoView(last.id);
10671079
}
10681080

10691081
export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout {

‎src/tui/shell/index.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
syncNoticeAfterLayout,
6767
syncPromptRows,
6868
syncTranscriptSpacer,
69+
followTranscriptTail,
6970
} from "./chrome.js";
7071
import {
7172
clearPendingAttachments,
@@ -202,6 +203,7 @@ export function createAppShell(
202203
stickyScroll: true,
203204
stickyStart: "bottom",
204205
scrollY: true,
206+
viewportCulling: true,
205207
focusable: true,
206208
rootOptions: { backgroundColor: UI.ground },
207209
contentOptions: { backgroundColor: UI.ground },
@@ -215,8 +217,9 @@ export function createAppShell(
215217
// them (armed rows stop propagation, so a click opens exactly once).
216218
armMarkdownLinks(transcript, ctx);
217219

218-
// Leading filler that bottom-anchors a short transcript; see
219-
// `syncTranscriptSpacer`. Zero height until the first sync call.
220+
// Index 0 stays a zero-height spacer so row offsets do not move. Short
221+
// transcripts bottom-align through the scroll content (see
222+
// `syncTranscriptSpacer`), not by growing this filler.
220223
const transcriptSpacer = new BoxRenderable(ctx, {
221224
id: "shell-transcript-spacer",
222225
width: "100%",
@@ -388,6 +391,7 @@ export function createAppShell(
388391
// After a natural render, not at mutation time: the row's box needs a
389392
// layout pass to size itself, and claiming the padding first starves it.
390393
syncTranscriptSpacer(shell);
394+
followTranscriptTail(shell);
391395
syncNoticeAfterLayout(shell);
392396
};
393397

‎src/tui/shell/internals.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ export interface AppShell {
356356
readonly overlayView: ReturnType<typeof createOverlayView>;
357357
readonly overlayHost: BoxRenderable;
358358
readonly overlayTitle: TextRenderable;
359-
readonly overlayBody: BoxRenderable;
359+
readonly overlayBody: ScrollBoxRenderable;
360360
readonly prompt: PromptInput;
361361
readonly promptBox: BoxRenderable;
362362
/** The input's own row, bordered left and right only. */
@@ -723,11 +723,9 @@ interface ShellInternals {
723723

724724
export const internals = new WeakMap<AppShell, ShellInternals>();
725725

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. */
726+
/** Index-0 child of the transcript. Height stays 0: short logs bottom-align
727+
* via the scroll content's justify-end, not a filler resized every frame.
728+
* The node remains so row children keep a stable offset. */
731729
export const transcriptSpacers = new WeakMap<AppShell, BoxRenderable>();
732730

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

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

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -133,22 +133,25 @@ function placeholderOptions(count: number): SelectOption[] {
133133
}
134134

135135
/**
136-
* SelectRenderable keeps scroll offset and visible-item capacity private
137-
* (@opentui/core 0.5.10 exposes no accessors), so read them reflectively
138-
* and narrow instead of asserting a shape.
136+
* SelectRenderable keeps its scroll offset private. The painted window is the
137+
* same centered slice Select computes from its public height and selection,
138+
* so callers can read it without reflecting into private fields.
139139
*/
140-
function selectScrollState(select: SelectRenderable): {
141-
offset: number;
142-
visible: number;
143-
} {
144-
const numberProp = (name: string): number => {
145-
const value = Object.getOwnPropertyDescriptor(select, name)?.value;
146-
return typeof value === "number" ? value : 1;
147-
};
148-
return {
149-
offset: numberProp("scrollOffset"),
150-
visible: Math.max(1, numberProp("maxVisibleItems")),
151-
};
140+
function selectWindow(
141+
select: SelectRenderable,
142+
count: number,
143+
rowsPerItem: number,
144+
): { offset: number; visible: number } {
145+
if (count === 0) return { offset: 0, visible: 0 };
146+
const linesPerItem = Math.max(1, rowsPerItem);
147+
const visible = Math.max(1, Math.floor(select.height / linesPerItem));
148+
const half = Math.floor(visible / 2);
149+
const index = select.getSelectedIndex();
150+
const offset = Math.max(
151+
0,
152+
Math.min(index - half, Math.max(0, count - visible)),
153+
);
154+
return { offset, visible };
152155
}
153156

154157
export function createOverlayList(
@@ -165,6 +168,7 @@ export function createOverlayList(
165168

166169
const build = (): SelectRenderable =>
167170
new SelectRenderable(ctx, {
171+
id: "shell-overlay-select",
168172
options,
169173
selectedIndex: activeIndex,
170174
height: shape.items * shape.rowsPerItem,
@@ -221,7 +225,7 @@ export function createOverlayList(
221225
return shape.rowsPerItem;
222226
},
223227
get offset() {
224-
return selectScrollState(select).offset;
228+
return selectWindow(select, count, shape.rowsPerItem).offset;
225229
},
226230
get count() {
227231
return count;
@@ -251,7 +255,7 @@ export function createOverlayList(
251255
});
252256
},
253257
visibleRange() {
254-
const { offset, visible } = selectScrollState(select);
258+
const { offset, visible } = selectWindow(select, count, shape.rowsPerItem);
255259
return { start: offset, end: Math.min(count, offset + visible) };
256260
},
257261
};

0 commit comments

Comments
 (0)