From f7b53ac512d8285ad64553e1f7d22658feed3d52 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 13:16:23 -0700 Subject: [PATCH 1/5] feat(tui): scroll the transcript and overlay through ScrollBox --- src/tui/allow-once-reprompt.test.ts | 4 +-- src/tui/overlay-view.ts | 31 +++++++++++++++++++---- src/tui/shell/chrome.ts | 38 +++++++++++++++++++---------- src/tui/shell/index.ts | 8 ++++-- src/tui/shell/internals.ts | 10 +++----- src/tui/shell/overlay-list.ts | 38 ++++++++++++++++------------- 6 files changed, 84 insertions(+), 45 deletions(-) diff --git a/src/tui/allow-once-reprompt.test.ts b/src/tui/allow-once-reprompt.test.ts index 88115b6e7..a2f2dc8ad 100644 --- a/src/tui/allow-once-reprompt.test.ts +++ b/src/tui/allow-once-reprompt.test.ts @@ -484,7 +484,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss openSlash(shell); const list = defined(shell.overlayList, "slash list"); expect(list.select.isDestroyed).toBe(false); - expect(list.select.parent).toBe(shell.overlayView.body); + expect(list.select.parent).toBe(shell.overlayView.body.content); let resolved: unknown; emitter.emit("permission.gate", { @@ -501,7 +501,7 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss expect(shell.overlayKind).toBe("help"); expect(shell.overlayList).toBe(list); expect(list.select.isDestroyed).toBe(false); - expect(list.select.parent).toBe(shell.overlayView.body); + expect(list.select.parent).toBe(shell.overlayView.body.content); expect(shell.overlayView.body.getChildren()).toContain(list.select); }); }); diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index 5afd0d073..5654eadc0 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -1,5 +1,6 @@ import { BoxRenderable, + ScrollBoxRenderable, TextRenderable, type RenderContext, } from "@opentui/core"; @@ -316,13 +317,24 @@ export function createOverlayView(ctx: RenderContext) { content: " overlay", fg: UI.textDim, }); - const body = new BoxRenderable(ctx, { + const body = new ScrollBoxRenderable(ctx, { id: "shell-overlay-body", width: "100%", flexGrow: 1, - flexDirection: "column", - backgroundColor: UI.ground, + flexShrink: 1, + // The host scrolls as one surface. Arrow keys stay on the list selection; + // the scrollbox must not take focus and page itself. + focusable: false, + viewportCulling: true, + scrollY: true, + stickyScroll: false, + rootOptions: { backgroundColor: UI.ground }, + contentOptions: { backgroundColor: UI.ground }, + viewportOptions: { backgroundColor: UI.ground }, }); + body.horizontalScrollBar.visible = false; + body.horizontalScrollBar.focusable = false; + body.verticalScrollBar.focusable = false; host.add(title); host.add(body); @@ -374,6 +386,7 @@ export function createOverlayView(ctx: RenderContext) { description: "", })); body.add(list.select); + revealList(list); } /** Rule + two-line description zone, painted when `describe` is set. */ @@ -415,10 +428,17 @@ export function createOverlayView(ctx: RenderContext) { /** * Detach the select before `clearBody` destroys the body's children — the - * list owns it across paints. + * list owns it across paints. ScrollBox.add parents into content, so the + * select's parent is not the scrollbox itself. */ function detachList(list: OverlayList): void { - if (list.select.parent === body) body.remove(list.select); + if (list.select.parent) body.remove(list.select); + } + + /** Keep the live choice inside the host viewport after a paint or move. */ + function revealList(list: OverlayList): void { + if (list.select.parent === null || list.select.height <= 0) return; + body.scrollChildIntoView(list.select.id); } function paintList( @@ -459,6 +479,7 @@ export function createOverlayView(ctx: RenderContext) { else addOverlayRow(` ${OVERLAY_EMPTY_STATE}`, UI.textDim); paintAnswerRow(presentation.answer, contentWidth); paintDescriptionZone(presentation.describe, contentWidth); + revealList(list); } function paintTitle( diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index d0a2e3438..e1dfc853d 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -1047,23 +1047,35 @@ export function syncPromptRows(shell: AppShell): void { } /** - * Resize the leading filler to soak leftover viewport space (`scrollHeight` - * minus filler). Not at row-mutation time: mid-layout rows read short for a - * frame, so a filler grown then would bury one. The render-frame hook calls - * it after that pass. + * Pin a short transcript to the prompt. ScrollBox content is already at least + * the viewport tall (`minHeight: "100%"`); justify-end packs the rows against + * the bottom. The landing positions its own mark, so flex-end there would + * shove it twice. The index-0 spacer stays at height 0 — growing it was the + * old anchor. */ +const transcriptJustify = new WeakMap(); + export function syncTranscriptSpacer(shell: AppShell): void { const spacer = transcriptSpacers.get(shell); if (spacer === undefined) return; - // The landing already bottom-anchors its mark via the above/below split; a - // filler would double-count and squeeze it. - if (isLanding(shell)) { - if (spacer.height !== 0) spacer.height = 0; - return; - } - const rowsHeight = Math.max(0, shell.transcript.scrollHeight - spacer.height); - const nextHeight = Math.max(0, shell.transcript.height - rowsHeight); - if (spacer.height !== nextHeight) spacer.height = nextHeight; + if (spacer.height !== 0) spacer.height = 0; + const justify = isLanding(shell) ? "flex-start" : "flex-end"; + if (transcriptJustify.get(shell) === justify) return; + shell.transcript.contentOptions = { justifyContent: justify }; + transcriptJustify.set(shell, justify); +} + +/** Keep the newest row in view while the tail is followed. A child that has + * not been laid out reports `y === 0`; scrolling that into view yanks the + * viewport off the tail. */ +export function followTranscriptTail(shell: AppShell): void { + if (isLanding(shell) || !isTranscriptFollowing(shell)) return; + const last = shell.transcript.getChildren().at(-1); + if (last === undefined || last.id === "shell-transcript-spacer") return; + if (last.height <= 0) return; + const viewportY = shell.transcript.viewport.y; + if (last.y === 0 && viewportY !== 0) return; + shell.transcript.scrollChildIntoView(last.id); } export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout { diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 548a3ed2b..da4ac2ce7 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -66,6 +66,7 @@ import { syncNoticeAfterLayout, syncPromptRows, syncTranscriptSpacer, + followTranscriptTail, } from "./chrome.js"; import { clearPendingAttachments, @@ -202,6 +203,7 @@ export function createAppShell( stickyScroll: true, stickyStart: "bottom", scrollY: true, + viewportCulling: true, focusable: true, rootOptions: { backgroundColor: UI.ground }, contentOptions: { backgroundColor: UI.ground }, @@ -215,8 +217,9 @@ export function createAppShell( // them (armed rows stop propagation, so a click opens exactly once). armMarkdownLinks(transcript, ctx); - // Leading filler that bottom-anchors a short transcript; see - // `syncTranscriptSpacer`. Zero height until the first sync call. + // Index 0 stays a zero-height spacer so row offsets do not move. Short + // transcripts bottom-align through the scroll content (see + // `syncTranscriptSpacer`), not by growing this filler. const transcriptSpacer = new BoxRenderable(ctx, { id: "shell-transcript-spacer", width: "100%", @@ -388,6 +391,7 @@ export function createAppShell( // After a natural render, not at mutation time: the row's box needs a // layout pass to size itself, and claiming the padding first starves it. syncTranscriptSpacer(shell); + followTranscriptTail(shell); syncNoticeAfterLayout(shell); }; diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 64352d44d..b76d18ab4 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -356,7 +356,7 @@ export interface AppShell { readonly overlayView: ReturnType; readonly overlayHost: BoxRenderable; readonly overlayTitle: TextRenderable; - readonly overlayBody: BoxRenderable; + readonly overlayBody: ScrollBoxRenderable; readonly prompt: PromptInput; readonly promptBox: BoxRenderable; /** The input's own row, bordered left and right only. */ @@ -723,11 +723,9 @@ interface ShellInternals { export const internals = new WeakMap(); -/** Filler row sized to leftover viewport space so a short transcript - * bottom-anchors against the prompt box; settles at zero once rows fill it. - * A real child, not padding: `minHeight: "100%"` counts padding as viewport - * height, so `scrollHeight - spacer.height` isolates the rows. - * Index 0 is always the spacer. */ +/** Index-0 child of the transcript. Height stays 0: short logs bottom-align + * via the scroll content's justify-end, not a filler resized every frame. + * The node remains so row children keep a stable offset. */ export const transcriptSpacers = new WeakMap(); /** True while the landing composition is still mounted. */ diff --git a/src/tui/shell/overlay-list.ts b/src/tui/shell/overlay-list.ts index 96300053f..41e818cc1 100644 --- a/src/tui/shell/overlay-list.ts +++ b/src/tui/shell/overlay-list.ts @@ -133,22 +133,25 @@ function placeholderOptions(count: number): SelectOption[] { } /** - * SelectRenderable keeps scroll offset and visible-item capacity private - * (@opentui/core 0.5.10 exposes no accessors), so read them reflectively - * and narrow instead of asserting a shape. + * SelectRenderable keeps its scroll offset private. The painted window is the + * same centered slice Select computes from its public height and selection, + * so callers can read it without reflecting into private fields. */ -function selectScrollState(select: SelectRenderable): { - offset: number; - visible: number; -} { - const numberProp = (name: string): number => { - const value = Object.getOwnPropertyDescriptor(select, name)?.value; - return typeof value === "number" ? value : 1; - }; - return { - offset: numberProp("scrollOffset"), - visible: Math.max(1, numberProp("maxVisibleItems")), - }; +function selectWindow( + select: SelectRenderable, + count: number, + rowsPerItem: number, +): { offset: number; visible: number } { + if (count === 0) return { offset: 0, visible: 0 }; + const linesPerItem = Math.max(1, rowsPerItem); + const visible = Math.max(1, Math.floor(select.height / linesPerItem)); + const half = Math.floor(visible / 2); + const index = select.getSelectedIndex(); + const offset = Math.max( + 0, + Math.min(index - half, Math.max(0, count - visible)), + ); + return { offset, visible }; } export function createOverlayList( @@ -165,6 +168,7 @@ export function createOverlayList( const build = (): SelectRenderable => new SelectRenderable(ctx, { + id: "shell-overlay-select", options, selectedIndex: activeIndex, height: shape.items * shape.rowsPerItem, @@ -221,7 +225,7 @@ export function createOverlayList( return shape.rowsPerItem; }, get offset() { - return selectScrollState(select).offset; + return selectWindow(select, count, shape.rowsPerItem).offset; }, get count() { return count; @@ -251,7 +255,7 @@ export function createOverlayList( }); }, visibleRange() { - const { offset, visible } = selectScrollState(select); + const { offset, visible } = selectWindow(select, count, shape.rowsPerItem); return { start: offset, end: Math.min(count, offset + visible) }; }, }; From 7f1bc6e2fe7a096d9c2b2151f5caedac66fc6222 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 13:16:23 -0700 Subject: [PATCH 2/5] feat(tui): scroll the overlay list with the host scrollbar --- src/tui/allow-once-reprompt.test.ts | 7 +- src/tui/overlay-paint.test.ts | 10 ++- src/tui/overlay-view.ts | 39 +++------- src/tui/shell/internals.ts | 6 +- src/tui/shell/overlay-list.ts | 112 +++++++++++++++++++++++++--- 5 files changed, 126 insertions(+), 48 deletions(-) diff --git a/src/tui/allow-once-reprompt.test.ts b/src/tui/allow-once-reprompt.test.ts index a2f2dc8ad..4382983d8 100644 --- a/src/tui/allow-once-reprompt.test.ts +++ b/src/tui/allow-once-reprompt.test.ts @@ -484,7 +484,8 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss openSlash(shell); const list = defined(shell.overlayList, "slash list"); expect(list.select.isDestroyed).toBe(false); - expect(list.select.parent).toBe(shell.overlayView.body.content); + expect(list.viewport.parent).toBe(shell.overlayView.body); + expect(list.select.isDestroyed).toBe(false); let resolved: unknown; emitter.emit("permission.gate", { @@ -501,8 +502,8 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss expect(shell.overlayKind).toBe("help"); expect(shell.overlayList).toBe(list); expect(list.select.isDestroyed).toBe(false); - expect(list.select.parent).toBe(shell.overlayView.body.content); - expect(shell.overlayView.body.getChildren()).toContain(list.select); + expect(list.viewport.parent).toBe(shell.overlayView.body); + expect(shell.overlayView.body.getChildren()).toContain(list.viewport); }); }); diff --git a/src/tui/overlay-paint.test.ts b/src/tui/overlay-paint.test.ts index 4d75355f5..0321db15c 100644 --- a/src/tui/overlay-paint.test.ts +++ b/src/tui/overlay-paint.test.ts @@ -83,6 +83,8 @@ async function paintOverlay( /** * Every interior row must be blank or exactly one expected overlay row; two * renderables sharing cells produces a hybrid string that matches nothing. + * A native ScrollBar thumb occupies the last column when the list overflows; + * that glyph is not a second copy of the row. */ function expectCleanInterior( interior: readonly string[], @@ -90,8 +92,9 @@ function expectCleanInterior( ): void { const allowed = new Set(expected.map((e) => e.trimEnd())); for (const row of interior) { - if (row.trim().length === 0) continue; - expect(allowed.has(row)).toBe(true); + const withoutBar = row.replace(/[▀▄█▌▐░▒▓]+$/u, "").trimEnd(); + if (withoutBar.length === 0) continue; + expect(allowed.has(withoutBar)).toBe(true); } } @@ -125,7 +128,8 @@ describe("overlay host never shares cells with the prompt border", () => { expectCleanInterior(interior.slice(1), expected); // The selected row must be intact, not overwritten by the model label. - expect(interior).toContain(` ${ITEMS[0]}`); + // A ScrollBar thumb may sit on the same line, so match the label prefix. + expect(interior.some((row) => row.startsWith(` ${ITEMS[0]}`))).toBe(true); for (const row of interior) { expect(row.includes(MODEL_LABEL)).toBe(false); expect(row.includes("alice")).toBe(false); diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index 5654eadc0..b0a6c63c2 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -1,6 +1,5 @@ import { BoxRenderable, - ScrollBoxRenderable, TextRenderable, type RenderContext, } from "@opentui/core"; @@ -317,24 +316,13 @@ export function createOverlayView(ctx: RenderContext) { content: " overlay", fg: UI.textDim, }); - const body = new ScrollBoxRenderable(ctx, { + const body = new BoxRenderable(ctx, { id: "shell-overlay-body", width: "100%", flexGrow: 1, - flexShrink: 1, - // The host scrolls as one surface. Arrow keys stay on the list selection; - // the scrollbox must not take focus and page itself. - focusable: false, - viewportCulling: true, - scrollY: true, - stickyScroll: false, - rootOptions: { backgroundColor: UI.ground }, - contentOptions: { backgroundColor: UI.ground }, - viewportOptions: { backgroundColor: UI.ground }, + flexDirection: "column", + backgroundColor: UI.ground, }); - body.horizontalScrollBar.visible = false; - body.horizontalScrollBar.focusable = false; - body.verticalScrollBar.focusable = false; host.add(title); host.add(body); @@ -385,8 +373,8 @@ export function createOverlayView(ctx: RenderContext) { name: line, description: "", })); - body.add(list.select); - revealList(list); + body.add(list.viewport); + list.scrollActiveIntoView(); } /** Rule + two-line description zone, painted when `describe` is set. */ @@ -427,18 +415,11 @@ export function createOverlayView(ctx: RenderContext) { } /** - * Detach the select before `clearBody` destroys the body's children — the - * list owns it across paints. ScrollBox.add parents into content, so the - * select's parent is not the scrollbox itself. + * Detach the list viewport before `clearBody` destroys the body's children — + * the list owns the select inside that ScrollBox across paints. */ function detachList(list: OverlayList): void { - if (list.select.parent) body.remove(list.select); - } - - /** Keep the live choice inside the host viewport after a paint or move. */ - function revealList(list: OverlayList): void { - if (list.select.parent === null || list.select.height <= 0) return; - body.scrollChildIntoView(list.select.id); + if (list.viewport.parent) body.remove(list.viewport); } function paintList( @@ -475,11 +456,11 @@ export function createOverlayView(ctx: RenderContext) { }); // An empty list renders nothing: the renderable would claim a background // row the chooser did not reserve. - if (presentation.items.length > 0) body.add(list.select); + if (presentation.items.length > 0) body.add(list.viewport); else addOverlayRow(` ${OVERLAY_EMPTY_STATE}`, UI.textDim); paintAnswerRow(presentation.answer, contentWidth); paintDescriptionZone(presentation.describe, contentWidth); - revealList(list); + list.scrollActiveIntoView(); } function paintTitle( diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index b76d18ab4..df7432191 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -356,7 +356,7 @@ export interface AppShell { readonly overlayView: ReturnType; readonly overlayHost: BoxRenderable; readonly overlayTitle: TextRenderable; - readonly overlayBody: ScrollBoxRenderable; + readonly overlayBody: BoxRenderable; readonly prompt: PromptInput; readonly promptBox: BoxRenderable; /** The input's own row, bordered left and right only. */ @@ -546,6 +546,8 @@ export interface OverlayListRange { * rebuilds on geometry change. */ export interface OverlayList { readonly select: SelectRenderable; + /** ScrollBox that clips the select and owns the host ScrollBar. */ + readonly viewport: ScrollBoxRenderable; readonly activeIndex: number; /** Item-row capacity reserved by layout (not the renderable's row height). */ readonly height: number; @@ -557,6 +559,8 @@ export interface OverlayList { jump(index: number): void; setCount(count: number): void; setHeight(items: number, rowsPerItem?: number): void; + /** Move the host ScrollBox so the active row is inside the viewport. */ + scrollActiveIntoView(): void; visibleRange(): OverlayListRange; } diff --git a/src/tui/shell/overlay-list.ts b/src/tui/shell/overlay-list.ts index 41e818cc1..8865e2414 100644 --- a/src/tui/shell/overlay-list.ts +++ b/src/tui/shell/overlay-list.ts @@ -2,6 +2,8 @@ * The overlay list wrapper (SelectRenderable) and its selection delegations. */ import { + BoxRenderable, + ScrollBoxRenderable, SelectRenderable, type KeyEvent, type RenderContext, @@ -133,20 +135,19 @@ function placeholderOptions(count: number): SelectOption[] { } /** - * SelectRenderable keeps its scroll offset private. The painted window is the - * same centered slice Select computes from its public height and selection, - * so callers can read it without reflecting into private fields. + * Select keeps scroll offset private. The window is the same centered slice + * it would paint, taken from the reserved slot count rather than a reflective + * read. The ScrollBox viewport is what actually scrolls; this only names the + * slice. */ -function selectWindow( - select: SelectRenderable, +function listWindow( + index: number, count: number, - rowsPerItem: number, + slots: number, ): { offset: number; visible: number } { if (count === 0) return { offset: 0, visible: 0 }; - const linesPerItem = Math.max(1, rowsPerItem); - const visible = Math.max(1, Math.floor(select.height / linesPerItem)); + const visible = Math.max(1, slots); const half = Math.floor(visible / 2); - const index = select.getSelectedIndex(); const offset = Math.max( 0, Math.min(index - half, Math.max(0, count - visible)), @@ -166,12 +167,17 @@ export function createOverlayList( const activeIndex = opts.activeIndex ?? 0; let options = placeholderOptions(count); + const rowSpan = (): number => Math.max(1, shape.rowsPerItem); + const build = (): SelectRenderable => new SelectRenderable(ctx, { id: "shell-overlay-select", options, selectedIndex: activeIndex, - height: shape.items * shape.rowsPerItem, + // Full list, not the visible window. The viewport ScrollBox clips it and + // owns the scrollbar; a window-sized select would scroll a second time + // inside itself and the bar would never see overflow. + height: Math.max(rowSpan(), Math.max(shape.items, count) * rowSpan()), width: "100%", flexShrink: 0, showDescription: shape.rowsPerItem > 1, @@ -193,6 +199,72 @@ export function createOverlayList( let select = build(); + const viewport = new ScrollBoxRenderable(ctx, { + id: "shell-overlay-list-scroll", + width: "100%", + height: Math.max(1, shape.items * rowSpan()), + flexShrink: 0, + // Arrow keys stay on the shell's selection handler. A focusable scrollbox + // would page itself and steal the list. + focusable: false, + viewportCulling: true, + scrollY: true, + stickyScroll: false, + rootOptions: { backgroundColor: UI.ground }, + contentOptions: { backgroundColor: UI.ground }, + viewportOptions: { backgroundColor: UI.ground }, + }); + viewport.horizontalScrollBar.visible = false; + viewport.horizontalScrollBar.focusable = false; + viewport.verticalScrollBar.focusable = false; + // Vertical bar stays on the native auto rule: visible once the select is + // taller than the slot window. Forcing it off is the transcript's choice, + // not the overlay host's. + const placed = viewport.content.onSizeChange; + let revealing = false; + + // Absolute marker at the active row. scrollChildIntoView on the whole select + // is a no-op when the select is taller than the viewport (both edges outside). + const anchor = new BoxRenderable(ctx, { + id: "shell-overlay-active-anchor", + position: "absolute", + left: 0, + width: 1, + height: rowSpan(), + top: 0, + }); + viewport.add(select); + viewport.add(anchor); + + const reveal = (): void => { + if (revealing) return; + revealing = true; + try { + const row = rowSpan(); + const slots = Math.max(1, shape.items); + viewport.height = slots * row; + select.height = Math.max(row, Math.max(slots, count) * row); + const index = select.getSelectedIndex(); + anchor.height = row; + anchor.top = index * row; + const { offset } = listWindow(index, count, slots); + const y = count === 0 ? 0 : offset * row; + viewport.scrollTop = y; + if (count > 0 && anchor.y !== 0 && viewport.viewport.y !== 0) { + viewport.scrollChildIntoView(anchor.id); + } + // Applied last: scrollTop clamps until the bar has a content size, and + // that clamp rewrites translateY. The viewport paints this translation. + viewport.content.translateY = -y; + } finally { + revealing = false; + } + }; + viewport.content.onSizeChange = () => { + placed?.call(viewport.content); + reveal(); + }; + const reshape = (next: Partial): void => { const merged = { ...shape, ...next }; if ( @@ -205,16 +277,22 @@ export function createOverlayList( // (clamped to the new count) so a resize does not snap the cursor back. const current = select.getSelectedIndex(); shape = merged; + if (select.parent) viewport.remove(select); select = build(); + viewport.add(select, 0); select.setSelectedIndex( count === 0 ? 0 : Math.min(count - 1, Math.max(0, current)), ); + reveal(); }; + reveal(); + return { get select() { return select; }, + viewport, get activeIndex() { return select.getSelectedIndex(); }, @@ -225,7 +303,7 @@ export function createOverlayList( return shape.rowsPerItem; }, get offset() { - return selectWindow(select, count, shape.rowsPerItem).offset; + return listWindow(select.getSelectedIndex(), count, shape.items).offset; }, get count() { return count; @@ -233,6 +311,7 @@ export function createOverlayList( move(delta: number) { if (delta < 0) select.moveUp(-delta); else if (delta > 0) select.moveDown(delta); + reveal(); }, page(dir: -1 | 1) { this.move(dir * (shape.items > 1 ? shape.items - 1 : 1)); @@ -242,11 +321,13 @@ export function createOverlayList( select.setSelectedIndex( Math.max(0, Math.min(count - 1, Math.floor(index))), ); + reveal(); }, setCount(next: number) { count = Math.max(0, Math.floor(next)); options = placeholderOptions(count); select.options = options; + reveal(); }, setHeight(items: number, rowsPerItem?: number) { reshape({ @@ -254,8 +335,15 @@ export function createOverlayList( ...(rowsPerItem ? { rowsPerItem } : {}), }); }, + scrollActiveIntoView() { + reveal(); + }, visibleRange() { - const { offset, visible } = selectWindow(select, count, shape.rowsPerItem); + const { offset, visible } = listWindow( + select.getSelectedIndex(), + count, + shape.items, + ); return { start: offset, end: Math.min(count, offset + visible) }; }, }; From 96c0b6f2a0b942e4569c0861ad1061946e50bdf2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 13:16:24 -0700 Subject: [PATCH 3/5] fix(tui): keep one wheel detent up from the tail pinned --- src/tui/shell.test.ts | 33 +++++++++++++++++++++++++++++++++ src/tui/shell/internals.ts | 7 +++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/tui/shell.test.ts b/src/tui/shell.test.ts index dcb689bf9..35a65e52a 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -125,6 +125,39 @@ describe("createAppShell", () => { ); }); + test("one wheel detent up from a followed tail stays off the bottom", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }); + try { + for (let i = 0; i < 50; i++) { + appendTranscript(shell, `seed-${i}`); + } + await h.renderOnce(); + const max = Math.max( + 0, + shell.transcript.scrollHeight - shell.transcript.height, + ); + expect(max).toBeGreaterThan(1); + expect(isTranscriptFollowing(shell)).toBe(true); + + // One detent, not a multi-tick fling. The next frame must not + // treat max - 1 as the tail and scroll it back. + shell.transcript.scrollTop = max - 1; + await h.renderOnce(); + expect(shell.transcript.scrollTop).toBe(max - 1); + expect(isTranscriptFollowing(shell)).toBe(false); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + test("the notice says pinned only while the tail is off screen", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index df7432191..86f17c62a 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -498,11 +498,14 @@ export type PrimaryOverlayKind = | "mcp" | "plugin_credentials"; -/** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED). */ +/** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED). + * The bottom is `scrollTop >= max` only. `max - 1` is one wheel detent up; + * treating it as followed makes the next frame's `scrollChildIntoView` yank + * the tail back. */ export function isTranscriptFollowing(shell: AppShell): boolean { const { transcript } = shell; const max = Math.max(0, transcript.scrollHeight - transcript.height); - return transcript.scrollTop >= max - 1; + return transcript.scrollTop >= max; } /** Sticky-scroll mode label (surfaced on the notice row only when PINNED). */ From a43ed39ace1ab6f8b44cf2b2d3a79b3e4c38ab02 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 14:01:12 -0700 Subject: [PATCH 4/5] test(tui): find the overlay select inside its ScrollBox Choice names, empty descriptions, and stale-row replacement still assert painted behavior. The lookup was failing only because the select is no longer a direct child of the overlay body. --- src/tui/decision-truncation.test.ts | 4 ++++ src/tui/overlay-view.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/tui/decision-truncation.test.ts b/src/tui/decision-truncation.test.ts index d92374c60..737f5452a 100644 --- a/src/tui/decision-truncation.test.ts +++ b/src/tui/decision-truncation.test.ts @@ -41,8 +41,12 @@ const hintRequest: PermissionRequest = { function bodySelect( view: ReturnType, ): SelectRenderable { + // The list lives in the host ScrollBox, not as a direct body child. const found = view.body .getChildren() + .flatMap((row) => + row instanceof SelectRenderable ? [row] : row.getChildren(), + ) .find((row) => row instanceof SelectRenderable); if (!(found instanceof SelectRenderable)) throw new Error("expected the overlay list"); diff --git a/src/tui/overlay-view.test.ts b/src/tui/overlay-view.test.ts index 9e0851fc5..66ff98605 100644 --- a/src/tui/overlay-view.test.ts +++ b/src/tui/overlay-view.test.ts @@ -33,8 +33,12 @@ function bodyRows(view: ReturnType): string[] { function bodySelect( view: ReturnType, ): SelectRenderable { + // The list lives in the host ScrollBox, not as a direct body child. const found = view.body .getChildren() + .flatMap((row) => + row instanceof SelectRenderable ? [row] : row.getChildren(), + ) .find((row) => row instanceof SelectRenderable); if (!(found instanceof SelectRenderable)) throw new Error("expected the overlay list"); From 7a47ab463e5e630a34f40f228d13d0457a8bbaae Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 14:43:51 -0700 Subject: [PATCH 5/5] 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 --- src/tui/allow-once-reprompt.test.ts | 1 - src/tui/shell.test.ts | 31 +++++++++++++++++++++++++++ src/tui/shell/chrome.ts | 6 ++---- src/tui/shell/index.ts | 2 ++ src/tui/shell/internals.ts | 33 +++++++++++++++++++++++++---- src/tui/shell/overlay-list.ts | 4 ---- 6 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/tui/allow-once-reprompt.test.ts b/src/tui/allow-once-reprompt.test.ts index 4382983d8..cee2785fe 100644 --- a/src/tui/allow-once-reprompt.test.ts +++ b/src/tui/allow-once-reprompt.test.ts @@ -485,7 +485,6 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss const list = defined(shell.overlayList, "slash list"); expect(list.select.isDestroyed).toBe(false); expect(list.viewport.parent).toBe(shell.overlayView.body); - expect(list.select.isDestroyed).toBe(false); let resolved: unknown; emitter.emit("permission.gate", { diff --git a/src/tui/shell.test.ts b/src/tui/shell.test.ts index 35a65e52a..ea6e04e9c 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -125,6 +125,37 @@ describe("createAppShell", () => { ); }); + test("one wheel detent survives a same-height content size pass", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }); + try { + for (let i = 0; i < 50; i++) { + appendTranscript(shell, `seed-${i}`); + } + await h.renderOnce(); + const max = Math.max( + 0, + shell.transcript.scrollHeight - shell.transcript.height, + ); + expect(max).toBeGreaterThan(1); + const scrollHeight = shell.transcript.scrollHeight; + shell.transcript.scrollTop = max - 1; + shell.transcript.content.onSizeChange?.(); + expect(shell.transcript.scrollHeight).toBe(scrollHeight); + expect(shell.transcript.scrollTop).toBe(max - 1); + expect(isTranscriptFollowing(shell)).toBe(false); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + test("one wheel detent up from a followed tail stays off the bottom", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index e1dfc853d..ebba66383 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -1065,9 +1065,7 @@ export function syncTranscriptSpacer(shell: AppShell): void { transcriptJustify.set(shell, justify); } -/** Keep the newest row in view while the tail is followed. A child that has - * not been laid out reports `y === 0`; scrolling that into view yanks the - * viewport off the tail. */ +/** Keep the newest row in view while the tail is followed. */ export function followTranscriptTail(shell: AppShell): void { if (isLanding(shell) || !isTranscriptFollowing(shell)) return; const last = shell.transcript.getChildren().at(-1); @@ -1393,7 +1391,7 @@ export function replaceStreamRowAt( export function repaintTranscriptWindow(shell: AppShell): void { clearLandingMark(shell); shell.agentVoices = new Set(agentVoicesIn(shell.streamLog)); - // The bottom-anchor spacer (index 0) stays; the eviction notice (if any) + // Index 0 is the zero-height spacer and stays; the eviction notice (if any) // and every row get torn down and rebuilt from the log. for (const child of shell.transcript.getChildren().slice(1)) { shell.transcript.remove(child); diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index da4ac2ce7..1313daae1 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -34,6 +34,7 @@ import { flushStreamRowUpdates } from "../runtime-bridge.js"; import { type AppShell, type AppShellOptions, + bindTranscriptTailSticky, EMPTY_PRIMARY_BINDINGS, flashTimers, initShellInternals, @@ -213,6 +214,7 @@ export function createAppShell( // row restating it. transcript.verticalScrollBar.visible = false; transcript.horizontalScrollBar.visible = false; + bindTranscriptTailSticky(transcript); // Markdown links live on nodes we do not own; this bubbling handler arms // them (armed rows stop propagation, so a click opens exactly once). armMarkdownLinks(transcript, ctx); diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 86f17c62a..e673e3b75 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -498,16 +498,41 @@ export type PrimaryOverlayKind = | "mcp" | "plugin_credentials"; -/** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED). - * The bottom is `scrollTop >= max` only. `max - 1` is one wheel detent up; - * treating it as followed makes the next frame's `scrollChildIntoView` yank - * the tail back. */ +/** True when the viewport is on the tail (`scrollTop >= max`). */ export function isTranscriptFollowing(shell: AppShell): boolean { const { transcript } = shell; const max = Math.max(0, transcript.scrollHeight - transcript.height); return transcript.scrollTop >= max; } +/** Bottom sticky stays armed only on the tail, so one detent up stays pinned. */ +export function bindTranscriptTailSticky( + transcript: ScrollBoxRenderable, +): void { + const desc = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(transcript), + "scrollTop", + ); + const get = desc?.get; + const set = desc?.set; + if (get === undefined || set === undefined) { + throw new Error("ScrollBox scrollTop accessor is missing"); + } + const sync = (): void => { + const max = Math.max(0, transcript.scrollHeight - transcript.height); + transcript.stickyScroll = transcript.scrollTop >= max; + }; + Object.defineProperty(transcript, "scrollTop", { + configurable: true, + enumerable: true, + get: () => get.call(transcript), + set: (value: number) => { + set.call(transcript, value); + sync(); + }, + }); +} + /** Sticky-scroll mode label (surfaced on the notice row only when PINNED). */ export function stickyMode(shell: AppShell): "FOLLOW" | "PINNED" { return isTranscriptFollowing(shell) ? "FOLLOW" : "PINNED"; diff --git a/src/tui/shell/overlay-list.ts b/src/tui/shell/overlay-list.ts index 8865e2414..aaacc8960 100644 --- a/src/tui/shell/overlay-list.ts +++ b/src/tui/shell/overlay-list.ts @@ -223,8 +223,6 @@ export function createOverlayList( const placed = viewport.content.onSizeChange; let revealing = false; - // Absolute marker at the active row. scrollChildIntoView on the whole select - // is a no-op when the select is taller than the viewport (both edges outside). const anchor = new BoxRenderable(ctx, { id: "shell-overlay-active-anchor", position: "absolute", @@ -253,8 +251,6 @@ export function createOverlayList( if (count > 0 && anchor.y !== 0 && viewport.viewport.y !== 0) { viewport.scrollChildIntoView(anchor.id); } - // Applied last: scrollTop clamps until the bar has a content size, and - // that clamp rewrites translateY. The viewport paints this translation. viewport.content.translateY = -y; } finally { revealing = false;