diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 9a8326438ab..6b1afd353be 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -990,6 +990,11 @@ export function createSessionStore(options?: SessionStoreOptions) { // briefly hands the full fetched history to . Pre-slicing guarantees // resuming ANY session mounts at most MESSAGE_CAP rows. (Events buffered // across the resume RPC, replayed below, self-cap via capMessages per push.) + // With windowing ON (HERMES_TUI_WINDOWING — view/transcript.tsx S2) the + // view mounts only the BOTTOM window of this snapshot anyway: rows created + // deep in a bulk replace start as line-count-estimate spacers and are + // measured lazily. The pre-slice still bounds the windowing-OFF path and + // the store's own JS retention. const capped = snapshot.length > MESSAGE_CAP ? snapshot.slice(-MESSAGE_CAP) : snapshot setState('messages', capped) // A resume is a fresh view → SET (not accumulate) the dropped count to what the diff --git a/ui-opentui/src/logic/window.ts b/ui-opentui/src/logic/window.ts index 3a9056b969d..e16f6a04824 100644 --- a/ui-opentui/src/logic/window.ts +++ b/ui-opentui/src/logic/window.ts @@ -1,5 +1,5 @@ /** - * window — pure transcript-windowing math (slice S1 of docs/plans/ + * window — pure transcript-windowing math (slices S1+S2 of docs/plans/ * opentui-transcript-windowing.md, issue #27). The view (view/transcript.tsx) * replaces out-of-window rows with EXACT-HEIGHT empty boxes (1 yoga node, no * text buffers / native handles), so the mounted set stays ~3 viewports of @@ -9,7 +9,12 @@ * rows intersecting [scrollTop − margin, scrollTop + viewport + margin) * over CUMULATIVE row heights (exact recorded heights; a line-count * estimate stands in for never-measured rows), plus the never-window rows - * (streaming/live) and the bottom K rows (sticky-bottom region). + * (streaming/live) and the bottom K rows (sticky-bottom region). With + * `pinnedBottom` (S2) the window anchors to the BOTTOM of the content + * instead of `scrollTop`: during burst appends / a resume snapshot the + * sticky pin will land at the new bottom, but layout (and therefore + * scrollTop) lags the store — anchoring to the cumulative content bottom + * adjudicates appended rows immediately instead of one frame late. * - `shouldRecompute` — the hysteresis gate (≥ ¼ viewport via * `hysteresisFor`): a computed window only changes once scrollTop has * moved ≥ hysteresis from the anchor it was computed at, so swaps don't @@ -20,9 +25,20 @@ * via the sticky pin) or fully BELOW it (invisible by definition). Anything * intersecting the viewport would visibly move content: forbidden. * - `estimateMessageHeight` — the cheap line-count estimate for rows that - * have never been measured (resume history above the viewport). S1 never - * corrects a wrong estimate in place — it is fixed by remount (scrolling - * near) or by the S2 lazy measure pass, both governed by the jank rule. + * have never been measured (resume history above the viewport). A wrong + * estimate is fixed by remount (scrolling near) or by the S2 idle measure + * pass, both governed by the jank rule. + * - `edgeMeasureBatch` (S2 — design §4, the SIMPLE choice): @opentui/core + * cannot lay a renderable out without parenting it into the live tree + * (layout is the tree's Yoga pass), so true offscreen measurement isn't + * available. Instead the idle pass mounts a small batch of never-measured + * rows nearest the bottom window edge — they are the next to be seen when + * the user scrolls back — records their exact heights, and lets the next + * window recompute swap them back to (now exact) spacers. Estimates far + * from the window stay estimates until the march reaches them. + * - `windowRowStats` — a DEV counter (current / peak simultaneously-mounted + * real rows) the integration tests assert against and the bench can read + * (transcript.tsx exposes it on globalThis behind HERMES_TUI_WINDOW_STATS). */ import type { Message, Part } from './store.ts' @@ -33,7 +49,7 @@ export interface WindowRow { * margins included) — or null when the row has never been measured. */ readonly height: number | null /** Line-count estimate used while `height` is null (see estimateMessageHeight). */ - readonly estimate?: number + readonly estimate?: number | undefined /** Always mounted regardless of the window (streaming/live rows — a remount * would restart native markdown streaming). */ readonly neverWindow: boolean @@ -49,6 +65,14 @@ export interface WindowParams { readonly fallbackHeight?: number /** The bottom K rows are always mounted (sticky-bottom region). */ readonly bottomK?: number + /** Anchor the window to the BOTTOM of the cumulative content instead of + * `scrollTop` (S2 append-time adjudication): while the view is pinned to + * the bottom, appended rows extend the content BELOW the last laid-out + * scrollTop — the sticky pin only catches up at the next layout pass. + * Anchoring to the content bottom adjudicates those rows immediately + * (new in-window rows mount, rows pushed past the margin become spacers) + * without waiting a frame. */ + readonly pinnedBottom?: boolean } export interface WindowResult { @@ -81,22 +105,91 @@ export function shouldRecompute(scrollTop: number, anchor: number | null, hyster export function computeWindow(params: WindowParams): WindowResult { const fallback = params.fallbackHeight ?? DEFAULT_FALLBACK_HEIGHT const bottomK = params.bottomK ?? 0 - const windowStart = params.scrollTop - params.margin - const windowEnd = params.scrollTop + params.viewportHeight + params.margin + const heightOf = (r: WindowRow): number => r.height ?? r.estimate ?? fallback + // pinnedBottom: the effective scrollTop is where the sticky pin will land — + // the cumulative content bottom minus one viewport (clamped at 0). + let effectiveTop = params.scrollTop + if (params.pinnedBottom) { + let contentHeight = 0 + for (const r of params.rows) contentHeight += heightOf(r) + effectiveTop = Math.max(0, contentHeight - params.viewportHeight) + } + const windowStart = effectiveTop - params.margin + const windowEnd = effectiveTop + params.viewportHeight + params.margin const total = params.rows.length const mounted = new Set() let top = 0 let index = 0 for (const r of params.rows) { - const height = r.height ?? r.estimate ?? fallback - const bottom = top + height + const bottom = top + heightOf(r) // half-open intersection: a row merely touching a window edge stays out. const intersects = bottom > windowStart && top < windowEnd if (intersects || r.neverWindow || index >= total - bottomK) mounted.add(r.key) top = bottom index++ } - return { mounted, anchor: params.scrollTop } + return { mounted, anchor: effectiveTop } +} + +/** Rows the S2 idle measure pass should mount next: up to `batch` never- + * measured, not-currently-mounted, windowable rows, NEAREST THE BOTTOM first + * (the bottom window edge is where a scroll-back enters history, so these are + * the next rows to be seen; the march then proceeds upward over idle pulses). + * Never-window rows are excluded — they are always mounted anyway. */ +export function edgeMeasureBatch(rows: readonly WindowRow[], mounted: ReadonlySet, batch: number): K[] { + const out: K[] = [] + for (let i = rows.length - 1; i >= 0 && out.length < batch; i--) { + const r = rows[i] + if (!r || r.height !== null || r.neverWindow || mounted.has(r.key)) continue + out.push(r.key) + } + return out +} + +/** Default idle delay before a lazy measure pulse (design §4): no appends, no + * scroll movement, no running turn for this long → mount one small batch. */ +export const DEFAULT_MEASURE_IDLE_MS = 1000 + +/** Parse `HERMES_TUI_WINDOW_IDLE_MS` (TUI-only DEV/test knob): the idle delay + * before a lazy measure pulse. A non-negative integer → that delay (0 = pulse + * on every idle frame — the headless tests use this to make pulses + * deterministic); unset/garbage → DEFAULT_MEASURE_IDLE_MS. */ +export function measureIdleDelayMs(value: string | undefined): number { + const v = value?.trim() ?? '' + if (!/^\d+$/.test(v)) return DEFAULT_MEASURE_IDLE_MS + return Number.parseInt(v, 10) +} + +// ── DEV counter: simultaneously-mounted real rows (current + peak) ──────── +// Two ints, always maintained (the cost is negligible); the integration tests +// assert `peakMounted` stays bounded during bursts/resume, and transcript.tsx +// exposes the live object on globalThis when HERMES_TUI_WINDOW_STATS is set so +// the bench can sample it. One transcript per process in practice; tests that +// mount several reset between phases. +export interface WindowRowStats { + mounted: number + peakMounted: number +} + +const rowStats: WindowRowStats = { mounted: 0, peakMounted: 0 } + +/** The live stats object (mutated in place — safe to hold a reference). */ +export function windowRowStats(): Readonly { + return rowStats +} + +export function noteRowMounted(): void { + rowStats.mounted++ + if (rowStats.mounted > rowStats.peakMounted) rowStats.peakMounted = rowStats.mounted +} + +export function noteRowUnmounted(): void { + rowStats.mounted-- +} + +/** Reset the peak to the CURRENT mounted count (rows still live stay counted). */ +export function resetWindowRowStats(): void { + rowStats.peakMounted = rowStats.mounted } /** @@ -134,33 +227,37 @@ function lineCount(text: string): number { } /** Estimated rendered lines of one part: text → its line count (view strips - * leading/trailing blanks — mirror that); tool/reasoning → 1 collapsed - * header line (the default render for settled, never-mounted history). */ -function partLines(part: Part): number { - if (part.type === 'text') return lineCount(part.text.replace(/^\n+|\n+$/g, '')) + * leading/trailing blanks — mirror that) + the settled block's `⧉ copy` chip + * line when `chips`; tool/reasoning → 1 collapsed header line (the default + * render for settled, never-mounted history). */ +function partLines(part: Part, chips: boolean): number { + if (part.type === 'text') return lineCount(part.text.replace(/^\n+|\n+$/g, '')) + (chips ? 1 : 0) return 1 // collapsed tool/reasoning header line } /** * Cheap line-count height estimate for a row that has never been measured - * (S1: resume history above the viewport). Deliberately ignores soft wrapping + * (resume history above the viewport). Deliberately ignores soft wrapping * — it is a placeholder until the row is actually mounted/measured, and a * wrong value may only be corrected per `correctionIsLegal` (or left until * remount). `spacing` is the row's turnSpacing margins; `gap` the inter-part - * blank line (0 in /compact). + * blank line (0 in /compact); `chips` mirrors the view's per-block `⧉ copy` + * line (settled non-system rows outside /compact — messageLine.tsx CopyChip). */ export function estimateMessageHeight( - message: Pick, + message: Pick & { readonly role?: Message['role'] }, spacing: { readonly top: number; readonly bottom: number }, - gap: number + gap: number, + chips = false ): number { const parts = message.parts let content: number if (parts && parts.length > 0) { content = gap * (parts.length - 1) - for (const part of parts) content += partLines(part) + for (const part of parts) content += partLines(part, chips) } else { content = lineCount(message.text) + if (chips && message.role !== undefined && message.role !== 'system' && message.text.trim()) content += 1 } return Math.min(ESTIMATE_MAX_LINES, Math.max(1, content)) + spacing.top + spacing.bottom } diff --git a/ui-opentui/src/test/transcriptWindow.test.tsx b/ui-opentui/src/test/transcriptWindow.test.tsx index d2240e10be0..668be60258a 100644 --- a/ui-opentui/src/test/transcriptWindow.test.tsx +++ b/ui-opentui/src/test/transcriptWindow.test.tsx @@ -1,5 +1,5 @@ /** - * Transcript windowing S1 — headless integration (view/transcript.tsx + + * Transcript windowing S1+S2 — headless integration (view/transcript.tsx + * logic/window.ts behind HERMES_TUI_WINDOWING). Proves, against the REAL * renderer tree: * - out-of-window rows are actually UNMOUNTED (far fewer live renderables @@ -7,24 +7,39 @@ * - spacers are EXACT-height (total scrollHeight identical ON vs OFF — the * zero-jank invariant), * - scrolling far away REMOUNTS spaced-out rows (content paints again), - * - the flag OFF renders the legacy tree (no wrappers, everything mounted). + * - the flag OFF renders the legacy tree (no wrappers, everything mounted), + * and the S2 slices: + * - append-time adjudication: bursting 1500 appends keeps the PEAK + * simultaneously-mounted row count bounded (< 120) — rows scrolled past + * the margin become spacers without a manual scroll, + * - resume (commitSnapshot): a bulk snapshot mounts only the bottom window; + * everything above starts as estimate spacers and remounts on scroll-back, + * - the idle exact-measure march + spacer corrections are ZERO-JANK: with + * wrong estimates (soft-wrapped rows), idle pulses fix spacer heights + * while the visible frame stays byte-identical — sticky-bottom pinning + * compensates when pinned, explicit scrollTop compensation when reading + * mid-history. */ import { ScrollBoxRenderable, type Renderable } from '@opentui/core' import { useRenderer } from '@opentui/solid' import { afterEach, describe, expect, test } from 'vitest' -import { createSessionStore } from '../logic/store.ts' +import { createSessionStore, type Message } from '../logic/store.ts' +import { resetWindowRowStats, windowRowStats } from '../logic/window.ts' import { ThemeProvider } from '../view/theme.tsx' import { Transcript } from '../view/transcript.tsx' import { renderProbe, type RenderProbe } from './lib/render.ts' type Store = ReturnType -const ENV_KEY = 'HERMES_TUI_WINDOWING' -const envBefore = process.env[ENV_KEY] +const ENV_KEYS = ['HERMES_TUI_WINDOWING', 'HERMES_TUI_WINDOW_IDLE_MS'] as const +const envBefore = ENV_KEYS.map(k => process.env[k]) afterEach(() => { - if (envBefore === undefined) delete process.env[ENV_KEY] - else process.env[ENV_KEY] = envBefore + ENV_KEYS.forEach((k, i) => { + const v = envBefore[i] + if (v === undefined) delete process.env[k] + else process.env[k] = v + }) }) /** Seed `n` settled one-line system rows (flat text — no async markdown). */ @@ -47,7 +62,7 @@ interface Mounted { } async function mountTranscript(store: Store, windowing: '1' | '0'): Promise { - process.env[ENV_KEY] = windowing + process.env.HERMES_TUI_WINDOWING = windowing let root: Renderable | undefined function Grab() { root = useRenderer().root @@ -124,3 +139,173 @@ describe('transcript windowing (HERMES_TUI_WINDOWING) — S1 machinery', () => { } }) }) + +// ── S2 — append-time adjudication + windowed resume + idle exact-measure ── + +/** Drop the right-most columns (scrollbar territory): corrections legitimately + * resize the thumb as estimates become exact — that is NOT content movement. */ +function clipScrollbar(frame: string): string { + return frame + .split('\n') + .map(line => line.slice(0, -2).replace(/\s+$/, '')) + .join('\n') +} + +describe('transcript windowing — S2 append-time adjudication', () => { + test('bursting 1500 appends keeps the peak mounted-row count bounded (< 120)', async () => { + const onStore = createSessionStore() + onStore.apply({ type: 'gateway.ready' }) + const on = await mountTranscript(onStore, '1') + try { + resetWindowRowStats() + // Burst: 100 appends per frame — the per-append adjudication must window + // rows out as they pass the margin, NOT wait for the next frame tick. + for (let i = 0; i < 1500; i++) { + onStore.pushSystem(i % 5 === 4 ? `row-${i} marker\nsecond line\nthird line` : `row-${i} marker`) + if (i % 100 === 99) await on.probe.settle() + } + for (let i = 0; i < 6; i++) await on.probe.settle() + expect(windowRowStats().peakMounted).toBeLessThan(120) + // pinned at the bottom: the tail painted (live rows mount instantly) + expect(on.probe.frame()).toContain('row-1499 marker') + + // ZERO-JANK INVARIANT survives the burst: spacers (measured or + // estimated — these rows never soft-wrap) occupy EXACTLY the height the + // full tree would. (The store cap trims both to the same 1000 rows.) + const offStore = createSessionStore() + offStore.apply({ type: 'gateway.ready' }) + for (let i = 0; i < 1500; i++) { + offStore.pushSystem(i % 5 === 4 ? `row-${i} marker\nsecond line\nthird line` : `row-${i} marker`) + } + const off = await mountTranscript(offStore, '0') + try { + expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight) + } finally { + off.probe.destroy() + } + + // scroll-to-top remounts burst rows that were never painted + const sb = on.scrollbox() + sb.scrollTo(0) + for (let i = 0; i < 6; i++) await on.probe.settle() + // the cap kept the newest 1000 rows → the oldest surviving row is 500 + expect(on.probe.frame()).toContain('row-500 marker') + } finally { + on.probe.destroy() + } + }, 120_000) +}) + +describe('transcript windowing — S2 windowed resume (commitSnapshot)', () => { + function snapshot(n: number): Message[] { + const out: Message[] = [] + for (let i = 0; i < n; i++) { + if (i % 3 === 0) out.push({ role: 'user', text: `question-${i} marker` }) + else if (i % 3 === 1) out.push({ role: 'assistant', text: `answer-${i} marker\nwith a second line` }) + else out.push({ role: 'system', text: `note-${i} marker` }) + } + return out + } + + test('a bulk snapshot mounts only the bottom window; history starts as estimate spacers', async () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + const on = await mountTranscript(store, '1') + try { + resetWindowRowStats() + store.hydrate(() => snapshot(600)) + for (let i = 0; i < 6; i++) await on.probe.settle() + // Only the bottom window (+ bottom-30 sticky region) ever mounted — the + // 600-row snapshot must NOT transiently mount everything. + expect(windowRowStats().peakMounted).toBeLessThan(120) + expect(on.probe.frame()).toContain('answer-598 marker') + + // estimate spacers above are exact for these unwrapped rows (incl. the + // ⧉ copy chip line on settled user/assistant rows) — scrollHeight + // matches the fully-mounted legacy tree. + const offStore = createSessionStore() + offStore.apply({ type: 'gateway.ready' }) + const off = await mountTranscript(offStore, '0') + try { + offStore.hydrate(() => snapshot(600)) + for (let i = 0; i < 6; i++) await off.probe.settle() + expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight) + } finally { + off.probe.destroy() + } + + // scroll-back into never-mounted history remounts it + on.scrollbox().scrollTo(0) + for (let i = 0; i < 6; i++) await on.probe.settle() + expect(on.probe.frame()).toContain('question-0 marker') + } finally { + on.probe.destroy() + } + }, 60_000) +}) + +describe('transcript windowing — S2 idle exact-measure + zero-jank corrections', () => { + // Every 4th row soft-wraps (~120 chars at width 50): the line-count estimate + // is WRONG (1 line vs 3), so the idle march must correct spacer heights — + // without ever moving visible content. + function wrappySeed(n: number): Store { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + const long = 'wrap '.repeat(24).trim() // ~119 chars → 3 wrapped lines + for (let i = 0; i < n; i++) store.pushSystem(i % 4 === 0 ? `row-${i} ${long}` : `row-${i} marker`) + return store + } + + // 400 rows: the mount itself runs ~10 frames (≈100 rows measured by the + // march before the baseline is captured) — enough unmeasured, wrongly- + // estimated history must REMAIN above for the assertions to bite. + const WRAPPY_ROWS = 400 + + test('pinned at the bottom: sticky pinning absorbs above-viewport corrections (frame is byte-stable)', async () => { + process.env.HERMES_TUI_WINDOW_IDLE_MS = '0' // pulse every idle frame + const on = await mountTranscript(wrappySeed(WRAPPY_ROWS), '1') + try { + const sb = on.scrollbox() + const before = clipScrollbar(on.probe.frame()) + const shBefore = sb.scrollHeight + // idle pulses march up the history, mounting+measuring 10 rows at a time + for (let i = 0; i < 50; i++) { + await on.probe.settle() + expect(clipScrollbar(on.probe.frame())).toBe(before) // ZERO jank, every pulse + } + // corrections actually happened: the wrapped rows were under-estimated + expect(sb.scrollHeight).toBeGreaterThan(shBefore) + // ...and converged to the legacy tree's exact total + const off = await mountTranscript(wrappySeed(WRAPPY_ROWS), '0') + try { + expect(sb.scrollHeight).toBe(off.scrollbox().scrollHeight) + } finally { + off.probe.destroy() + } + } finally { + on.probe.destroy() + } + }, 120_000) + + test('reading mid-history: above-viewport corrections compensate scrollTop in the same frame', async () => { + process.env.HERMES_TUI_WINDOW_IDLE_MS = '0' + const on = await mountTranscript(wrappySeed(WRAPPY_ROWS), '1') + try { + const sb = on.scrollbox() + // leave the sticky pin and park mid-history (estimates above AND below) + sb.scrollTo(Math.floor(sb.scrollHeight / 2)) + for (let i = 0; i < 6; i++) await on.probe.settle() // window remount settles + const baseline = clipScrollbar(on.probe.frame()) + const scrollTopBefore = sb.scrollTop + for (let i = 0; i < 50; i++) { + await on.probe.settle() + expect(clipScrollbar(on.probe.frame())).toBe(baseline) // ZERO jank + } + // the under-estimated rows ABOVE the viewport grew; scrollTop was + // compensated by exactly that growth — that's WHY the frame held still. + expect(sb.scrollTop).toBeGreaterThan(scrollTopBefore) + } finally { + on.probe.destroy() + } + }, 120_000) +}) diff --git a/ui-opentui/src/test/window.test.ts b/ui-opentui/src/test/window.test.ts index a6b033a8b89..7ad0e071c2f 100644 --- a/ui-opentui/src/test/window.test.ts +++ b/ui-opentui/src/test/window.test.ts @@ -1,9 +1,11 @@ /** * window.ts — pure transcript-windowing math (design: docs/plans/ - * opentui-transcript-windowing.md, slice S1). Table-tests the window calc + * opentui-transcript-windowing.md, slices S1+S2). Table-tests the window calc * (viewport ± margin intersection over cumulative exact heights), the * hysteresis recompute gate, the never-window / bottom-K rules, the - * null-height estimate fallback, and the correction-legality jank rule. + * null-height estimate fallback, the correction-legality jank rule, the S2 + * pinned-bottom (append-time) anchoring, the idle edge-measure batch picker, + * the idle-delay knob, and the DEV mounted-row counters. */ import { describe, expect, test } from 'vitest' @@ -11,9 +13,16 @@ import type { Message } from '../logic/store.ts' import { computeWindow, correctionIsLegal, + DEFAULT_MEASURE_IDLE_MS, + edgeMeasureBatch, estimateMessageHeight, hysteresisFor, + measureIdleDelayMs, + noteRowMounted, + noteRowUnmounted, + resetWindowRowStats, shouldRecompute, + windowRowStats, type WindowRow } from '../logic/window.ts' @@ -118,6 +127,68 @@ describe('computeWindow — viewport ± margin intersection', () => { }) }) +describe('computeWindow — pinnedBottom (S2 append-time anchoring)', () => { + test('anchors the window to the content BOTTOM regardless of a stale scrollTop', () => { + // 100 rows × 10 → content 1000; viewport 40, margin 40. scrollTop is a + // STALE 0 (layout lagging a burst append), but pinnedBottom anchors to + // 1000 − 40 = 960 → window [920, 1040) → rows 92..99. + const result = computeWindow({ + rows: uniform(100, 10), + scrollTop: 0, + viewportHeight: 40, + margin: 40, + pinnedBottom: true + }) + expect(mountedKeys(result)).toEqual([92, 93, 94, 95, 96, 97, 98, 99]) + expect(result.anchor).toBe(960) + }) + + test('appended rows past the margin become spacers without any scroll movement', () => { + // The append-time rule: grow 100 → 200 rows at the same stale scrollTop — + // the window slides to the NEW bottom; the old bottom rows fall out. + const before = computeWindow({ + rows: uniform(100, 10), + scrollTop: 0, + viewportHeight: 40, + margin: 40, + pinnedBottom: true + }) + const after = computeWindow({ + rows: uniform(200, 10), + scrollTop: 0, + viewportHeight: 40, + margin: 40, + pinnedBottom: true + }) + expect(before.mounted.has(99)).toBe(true) + expect(after.mounted.has(99)).toBe(false) + expect(after.mounted.has(199)).toBe(true) + }) + + test('short content (fits the viewport) clamps to scrollTop 0 → everything mounted', () => { + const result = computeWindow({ + rows: uniform(3, 10), + scrollTop: 0, + viewportHeight: 40, + margin: 40, + pinnedBottom: true + }) + expect(result.mounted.size).toBe(3) + expect(result.anchor).toBe(0) + }) + + test('estimates participate in the bottom anchoring (never-measured history)', () => { + // 5 estimate-only rows of 100 above 5 exact rows of 10 → content 550; + // viewport 40, margin 0 → window [510, 550) → only the exact bottom rows. + const rows = [ + ...Array.from({ length: 5 }, (_, i) => row(i, null, { estimate: 100 })), + ...Array.from({ length: 5 }, (_, i) => row(5 + i, 10)) + ] + const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 40, margin: 0, pinnedBottom: true }) + expect(mountedKeys(result)).toEqual([6, 7, 8, 9]) + }) +}) + describe('computeWindow — never-window and bottom-K rules', () => { test('neverWindow rows stay mounted however far outside the window', () => { const rows = uniform(100, 10) @@ -234,4 +305,71 @@ describe('estimateMessageHeight — line-count estimate for never-mounted rows', const text = Array.from({ length: 10_000 }, (_, i) => `l${i}`).join('\n') expect(estimateMessageHeight({ text }, { top: 0, bottom: 0 }, 0)).toBeLessThanOrEqual(500) }) + + test('chips: settled non-system rows count the ⧉ copy line; system rows do not', () => { + const spacing0 = { top: 0, bottom: 0 } + expect(estimateMessageHeight({ role: 'user', text: 'hi' }, spacing0, 1, true)).toBe(2) + expect(estimateMessageHeight({ role: 'system', text: 'note' }, spacing0, 1, true)).toBe(1) + expect(estimateMessageHeight({ role: 'user', text: 'hi' }, spacing0, 1, false)).toBe(1) + // parts: one chip line per text block, none for tool headers + const message: Pick = { + text: '', + parts: [ + { type: 'text', id: 'p1', text: 'one\ntwo' }, + { type: 'tool', id: 't1', name: 'terminal', state: 'complete' } + ] + } + // (2 text + 1 chip) + 1 tool + 1 gap + expect(estimateMessageHeight(message, spacing0, 1, true)).toBe(5) + }) +}) + +describe('edgeMeasureBatch — the S2 idle measure picker', () => { + test('picks never-measured, unmounted rows nearest the bottom first', () => { + const rows = [row(0, null), row(1, null), row(2, 10), row(3, null), row(4, 10)] + expect(edgeMeasureBatch(rows, new Set([4]), 10)).toEqual([3, 1, 0]) + }) + + test('respects the batch size (the march is incremental)', () => { + const rows = Array.from({ length: 20 }, (_, i) => row(i, null)) + expect(edgeMeasureBatch(rows, new Set(), 3)).toEqual([19, 18, 17]) + }) + + test('skips mounted, measured, and never-window rows', () => { + const rows = [row(0, null), row(1, null, { neverWindow: true }), row(2, null), row(3, 7)] + expect(edgeMeasureBatch(rows, new Set([2]), 10)).toEqual([0]) + }) + + test('fully measured transcript → nothing to do', () => { + expect(edgeMeasureBatch(uniform(5, 10), new Set(), 10)).toEqual([]) + }) +}) + +describe('measureIdleDelayMs — the idle-pulse knob', () => { + test('unset/garbage → the 1s default; integers (incl. 0) parse', () => { + expect(measureIdleDelayMs(undefined)).toBe(DEFAULT_MEASURE_IDLE_MS) + expect(measureIdleDelayMs('soon')).toBe(DEFAULT_MEASURE_IDLE_MS) + expect(measureIdleDelayMs('-5')).toBe(DEFAULT_MEASURE_IDLE_MS) + expect(measureIdleDelayMs('0')).toBe(0) + expect(measureIdleDelayMs(' 250 ')).toBe(250) + }) +}) + +describe('windowRowStats — the DEV mounted-row counters', () => { + test('tracks current and peak; reset re-bases the peak on the live count', () => { + resetWindowRowStats() + const base = windowRowStats().mounted + noteRowMounted() + noteRowMounted() + noteRowMounted() + expect(windowRowStats().mounted).toBe(base + 3) + expect(windowRowStats().peakMounted).toBe(base + 3) + noteRowUnmounted() + expect(windowRowStats().mounted).toBe(base + 2) + expect(windowRowStats().peakMounted).toBe(base + 3) // peak is sticky + resetWindowRowStats() + expect(windowRowStats().peakMounted).toBe(base + 2) // re-based, live rows kept + noteRowUnmounted() + noteRowUnmounted() + }) }) diff --git a/ui-opentui/src/view/transcript.tsx b/ui-opentui/src/view/transcript.tsx index d837408765c..a7c0c14738a 100644 --- a/ui-opentui/src/view/transcript.tsx +++ b/ui-opentui/src/view/transcript.tsx @@ -14,7 +14,7 @@ * A `ScrollAnchorProvider` gives collapse/expand toggles (tool/thinking) a handle * to hold the viewport in place so expanding doesn't yank to the bottom (#4). * - * ── Windowing (S1 of docs/plans/opentui-transcript-windowing.md, #27) ────── + * ── Windowing (S1+S2 of docs/plans/opentui-transcript-windowing.md, #27) ─── * Behind `HERMES_TUI_WINDOWING` (unset → ON; 0/false/no/off → OFF), each row is * wrapped in a measuring box (`onSizeChange` records its exact laid-out height, * margins included) and rows outside [scrollTop − viewport, scrollTop + @@ -24,27 +24,77 @@ * memory fix). The Solid `` unmount destroys the row's renderables * (@opentui/solid `_removeNode` → `destroyRecursively()` once unparented). * - * Driver: a renderer frame callback (`setFrameCallback` — scroll always - * triggers a render, so every scroll movement is observed; no extra timer) - * compares `scrollTop` to the last computation anchor with ≥ ¼-viewport - * hysteresis (logic/window.ts) and publishes the mounted-key set through one - * signal + `createSelector`, so only rows whose mounted-ness actually flipped - * re-render. Never windowed: streaming rows, the last row while a turn runs, - * and the bottom BOTTOM_ALWAYS_MOUNTED rows (see its doc). Rows the window has - * never adjudicated default to MOUNTED (new live rows must paint instantly). - * While a mouse selection is live the window FREEZES (no swaps — a swap would - * destroy highlighted renderables out from under the native selection walk). - * S1 never corrects a spacer height in place — wrong estimates (possible only - * for never-measured resume history above the viewport) are fixed by remount; - * `correctionIsLegal` governs anything smarter (S2). + * Drivers (S2 — append-time adjudication, not just scroll): + * - a renderer frame callback (`setFrameCallback` — scroll always triggers a + * render, so every scroll movement is observed; no extra timer) compares + * `scrollTop` AND `scrollHeight` to the last computation anchor with + * ≥ ¼-viewport hysteresis (logic/window.ts), + * - a `createComputed` on `messages.length` re-adjudicates SYNCHRONOUSLY on + * every append/splice — while pinned at the bottom the window is anchored + * to the cumulative content BOTTOM (`pinnedBottom`), so burst-appended rows + * are windowed out the moment they scroll past the margin instead of + * ballooning the mounted set until the next frame. + * Both publish the mounted-key set through one signal + `createSelector`, so + * only rows whose mounted-ness actually flipped re-render. + * + * Never windowed: streaming rows, the last row while a turn runs, and the + * bottom BOTTOM_ALWAYS_MOUNTED rows (see its doc). Rows the window has never + * adjudicated default to MOUNTED only when created within the bottom + * BOTTOM_ALWAYS_MOUNTED of the transcript or streaming (live rows paint + * instantly with zero added latency); a row created deep in a bulk snapshot + * (resume `commitSnapshot`) starts as an ESTIMATE spacer — a resumed 2k + * session mounts only the bottom window. While a mouse selection is live the + * window FREEZES (no swaps — a swap would destroy highlighted renderables out + * from under the native selection walk). + * + * Spacer corrections (S2, the zero-jank rule): when a remount/measure lands a + * height different from what the spacer occupied, the wrapper's onSizeChange + * fires DURING the layout traversal, before any cell is painted. If the view + * is pinned to the bottom the scrollbox's own sticky re-pin (which runs in the + * content's onSizeChange, i.e. BEFORE the row wrappers') has already + * re-anchored — nothing to do. Otherwise, for a row fully ABOVE the viewport + * (`correctionIsLegal`), scrollTop is compensated by the delta in the same + * frame, so visible content never moves. Corrections intersecting the + * viewport are forbidden and simply not applied (the swap itself is the + * accepted "fixed by remount" path). + * + * Lazy exact-measure (S2, design §4 — the documented SIMPLE choice): + * @opentui/core cannot lay out without parenting into the live tree, so there + * is no true offscreen measurement. When idle (no appends, no scroll movement, + * no running turn, no selection for HERMES_TUI_WINDOW_IDLE_MS ≈ 1s), a pulse + * mounts a small batch (MEASURE_BATCH_ROWS) of never-measured rows nearest the + * bottom window edge (`edgeMeasureBatch` — they're the next to be seen), + * records exact heights, and the next recompute swaps them back to now-exact + * spacers; corrections obey the jank rule above. Rows far from the window keep + * their estimates until the march reaches them. Scrolling itself measures the + * margin band as it always did. + * + * DEV stats: current/peak simultaneously-mounted rows (logic/window.ts + * `windowRowStats`) — exposed on `globalThis.__hermesTuiWindowStats` when + * HERMES_TUI_WINDOW_STATS is set, asserted bounded by the headless tests. + * + * Known S2 limits (documented, deferred): /compact·/details toggles and width + * resizes leave out-of-window spacer heights stale until remount or the idle + * march (resize invalidation is S3, design §5). */ import type { BoxRenderable, ScrollBoxRenderable } from '@opentui/core' import { useRenderer } from '@opentui/solid' -import { createMemo, createSelector, createSignal, For, onCleanup, onMount, Show } from 'solid-js' +import { createComputed, createMemo, createSelector, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js' import { envFlag } from '../logic/env.ts' import type { Message, SessionStore } from '../logic/store.ts' -import { computeWindow, estimateMessageHeight, hysteresisFor, shouldRecompute } from '../logic/window.ts' +import { + computeWindow, + correctionIsLegal, + edgeMeasureBatch, + estimateMessageHeight, + hysteresisFor, + measureIdleDelayMs, + noteRowMounted, + noteRowUnmounted, + shouldRecompute, + windowRowStats +} from '../logic/window.ts' import { DisplayProvider } from './display.tsx' import { HomeHint } from './homeHint.tsx' import { MessageLine, turnSpacing } from './messageLine.tsx' @@ -60,6 +110,11 @@ import { useTheme } from './theme.tsx' */ const BOTTOM_ALWAYS_MOUNTED = 30 +/** Rows mounted per idle measure pulse (design §4 "small batches"). Small + * enough that a pulse's churn is invisible work above the viewport; the march + * covers a full resume snapshot over a couple of minutes of idleness. */ +const MEASURE_BATCH_ROWS = 10 + /** The published window state: which keys are mounted, and which keys the * computation has SEEN (unseen keys default to mounted — see isMounted). */ interface WinState { @@ -117,45 +172,131 @@ export function Transcript(props: { store: SessionStore }) { // wrapper's onSizeChange value; includes the row's margins). Non-reactive: // spacers read it once at swap time, the frame driver reads it per compute. const heights = new Map() + // key → the height the LAYOUT currently occupies for the row (real or + // spacer/estimate — whatever the wrapper last laid out at). The S2 spacer- + // correction compares a fresh measurement against this to compute the delta + // that must be compensated when the change sits above the viewport. + const assumed = new Map() + // key → the mount default for rows the window has never adjudicated, + // decided at row CREATION: streaming rows and rows created within the bottom + // BOTTOM_ALWAYS_MOUNTED of the transcript mount (live rows paint instantly); + // anything deeper (a bulk resume snapshot) starts as an estimate spacer. + const defaults = new Map() + // key → the row's live measuring wrapper (the idle measure pull below reads + // post-layout heights for batch rows whose mount changed nothing). + const wrappers = new Map() + // key → cached settled-row height estimate (estimateFor scans the row text — + // caching keeps the per-append adjudication O(rows), not O(text)). Tagged + // with the /compact flag it was computed under. + const estimates = new Map() + let estimatesCompact = false const [winState, setWinState] = createSignal(undefined, { equals: sameWinState }) // Non-reactive mirror of the latest winState for event callbacks (onSizeChange // must not subscribe; createSelector reads are for tracked scopes). let liveWin: WinState | undefined + /** Non-reactive mounted-ness (for onSizeChange et al.): the window's verdict + * when the key has been adjudicated, its creation default otherwise. */ + const mountedNow = (key: number): boolean => { + if (!liveWin || !liveWin.known.has(key)) return defaults.get(key) ?? true + return liveWin.mounted.has(key) + } // Per-row mounted-ness: only rows whose answer FLIPPED re-run their . - // Keys the window has never adjudicated (no state yet / not in `known`, e.g. - // a message appended since the last compute) default to MOUNTED. const isMounted = createSelector(winState, (key: number, s: WinState | undefined) => { - return !s || !s.known.has(key) || s.mounted.has(key) + if (!s || !s.known.has(key)) return defaults.get(key) ?? true + return s.mounted.has(key) }) - const estimateFor = (message: Message): number => { + const estimateFor = (message: Message, key: number): number => { const compact = props.store.state.compact - return estimateMessageHeight(message, turnSpacing(message.role, compact), compact ? 0 : 1) + if (compact !== estimatesCompact) { + estimates.clear() + estimatesCompact = compact + } + const streaming = message.streaming ?? false + if (!streaming) { + const cached = estimates.get(key) + if (cached !== undefined) return cached + } + const estimate = estimateMessageHeight( + message, + turnSpacing(message.role, compact), + compact ? 0 : 1, + !compact && !streaming + ) + if (!streaming) estimates.set(key, estimate) + return estimate + } + // DEV stats exposure for the bench (HERMES_TUI_WINDOW_STATS): the live + // current/peak mounted-row counters from logic/window.ts. + if (envFlag(process.env.HERMES_TUI_WINDOW_STATS, false)) { + ;(globalThis as unknown as Record)['__hermesTuiWindowStats'] = windowRowStats() } - // ── window driver: per-frame scrollTop check (no scroll signal exists; the - // frame callback fires on every rendered frame, and scrolling always renders). + // ── window drivers: per-frame scrollTop/scrollHeight check (no scroll signal + // exists; the frame callback fires on every rendered frame, and scrolling + // always renders) + a synchronous re-adjudication on every append (S2). let anchor: number | null = null let lastCount = -1 - const tick = (): void => { + let lastScrollHeight = -1 + let lastScrollTop = -1 + let lastActivityAt = Date.now() + let lastPulseAt = 0 + let measureBatch: ReadonlySet = new Set() + const idleMs = measureIdleDelayMs(process.env.HERMES_TUI_WINDOW_IDLE_MS) + const tick = (force = false): void => { const sb = scroll() if (!sb) return // Selection freeze: the native selection walks the LIVE tree — swapping a // row out (destroying its renderables) mid-selection would corrupt the // highlight/copy. Frozen ≠ broken: unseen new rows default to mounted. - if (renderer.getSelection()?.isActive) return + if (renderer.getSelection()?.isActive) { + lastActivityAt = Date.now() + return + } const viewportHeight = sb.viewport.height if (viewportHeight <= 0) return const messages = props.store.state.messages + // Idle measure pull: a batch row whose mount landed at EXACTLY the spacer + // height fires no onSizeChange — read its post-layout height directly so + // the march advances past it. (Batch publishes happen only in frame- + // callback ticks, so by ANY later tick the batch's layout has run.) + for (const key of measureBatch) { + if (heights.has(key) || !mountedNow(key)) continue + const wrapper = wrappers.get(key) + const h = wrapper?.height ?? 0 + if (h > 0) { + heights.set(key, h) + assumed.set(key, h) + } + } const scrollTop = sb.scrollTop - const countChanged = messages.length !== lastCount - if (!countChanged && !shouldRecompute(scrollTop, anchor, hysteresisFor(viewportHeight))) return + const scrollHeight = sb.scrollHeight const running = props.store.state.info.running ?? false + const countChanged = messages.length !== lastCount + const hysteresis = hysteresisFor(viewportHeight) + // Content growth without a count change (streaming deltas) moves + // scrollHeight — treat it like scroll movement against the same hysteresis. + const heightMoved = lastScrollHeight !== -1 && Math.abs(scrollHeight - lastScrollHeight) >= hysteresis + const scrolled = shouldRecompute(scrollTop, anchor, hysteresis) + const now = Date.now() + if (countChanged || scrollTop !== lastScrollTop || running) lastActivityAt = now + lastScrollTop = scrollTop + // Idle measure pulse (design §4): only in frame-callback ticks (append + // ticks are activity by definition), only when truly idle, only while + // some row is still unmeasured (heights covers every live measured key). + const pulseDue = + !force && + !running && + heights.size < messages.length && + now - lastActivityAt >= idleMs && + now - lastPulseAt >= idleMs + if (!force && !countChanged && !heightMoved && !scrolled && !pulseDue) return const rows = messages.map((message, i) => { const key = keyOf(message) + const height = heights.get(key) ?? null return { key, - height: heights.get(key) ?? null, - estimate: estimateFor(message), + height, + estimate: height === null ? estimateFor(message, key) : undefined, // Never window: a streaming row (remount would restart native markdown // streaming) and the last row while a turn runs (deltas land there). // A row with an expanded tool/reasoning body is NOT detectable from @@ -165,19 +306,44 @@ export function Transcript(props: { store: SessionStore }) { neverWindow: (message.streaming ?? false) || (running && i === messages.length - 1) } }) + // Pinned to the bottom (sticky region): anchor the window to the content + // BOTTOM so rows appended since the last layout are adjudicated against + // where the pin will land, not a stale scrollTop (S2 append-time rule). + const atBottom = scrollTop >= scrollHeight - viewportHeight const result = computeWindow({ rows, scrollTop, viewportHeight, margin: viewportHeight, // 1 viewport each side (design §Mechanism 1) - bottomK: BOTTOM_ALWAYS_MOUNTED + bottomK: BOTTOM_ALWAYS_MOUNTED, + pinnedBottom: atBottom }) anchor = result.anchor lastCount = messages.length + lastScrollHeight = scrollHeight const known = new Set(rows.map(r => r.key)) - // The store cap splices old rows out — drop their recorded heights too. - if (countChanged) for (const key of heights.keys()) if (!known.has(key)) heights.delete(key) - liveWin = { mounted: result.mounted, known } + // The store cap splices old rows out — drop their per-key records too. + if (countChanged) { + for (const map of [heights, assumed, defaults, estimates]) { + for (const key of map.keys()) if (!known.has(key)) map.delete(key) + } + } + // Idle measure batch: mount the next never-measured rows nearest the + // window edge. The previous batch stays mounted until the NEXT pulse + // replaces it (not merely until a height lands): async row content — the + // native markdown tokenizes over a few frames — needs more than one layout + // pass before its recorded height is trustworthy. Once everything is + // measured the leftover batch drops back to (exact) spacers. + let batch = new Set() + if (pulseDue) { + batch = new Set(edgeMeasureBatch(rows, result.mounted, MEASURE_BATCH_ROWS)) + lastPulseAt = now + } else if (heights.size < messages.length) { + for (const key of measureBatch) if (known.has(key)) batch.add(key) + } + measureBatch = batch + const mounted = batch.size > 0 ? new Set([...result.mounted, ...batch]) : result.mounted + liveWin = { mounted, known } setWinState(liveWin) } onMount(() => { @@ -189,28 +355,88 @@ export function Transcript(props: { store: SessionStore }) { renderer.setFrameCallback(frame) onCleanup(() => renderer.removeFrameCallback(frame)) }) + // Append-time adjudication (S2): re-window synchronously when the transcript + // grows/shrinks, so a burst of appends can't balloon the mounted set between + // frames. `on(..., { defer })` keeps the tick untracked — only the length + // re-runs it (content deltas are the frame driver's scrollHeight check). + if (windowing) { + createComputed( + on( + () => props.store.state.messages.length, + () => tick(true), + { defer: true } + ) + ) + } /** One windowed row: a measuring wrapper around the real MessageLine or an * exact-height spacer. The wrapper stays mounted either way (1 box), so its * `onSizeChange` keeps the height record fresh while the row is real. */ const WindowedRow = (rowProps: { message: Message; index: () => number }) => { const key = keyOf(rowProps.message) + // Creation default for the not-yet-adjudicated row (see `defaults`): + // appended live rows land in the bottom region → mounted instantly; a row + // created deep inside a bulk snapshot starts as an estimate spacer. + // (Component bodies are untracked — these reads are a one-time snapshot.) + defaults.set( + key, + (rowProps.message.streaming ?? false) || + rowProps.index() >= props.store.state.messages.length - BOTTOM_ALWAYS_MOUNTED + ) let wrapper: BoxRenderable | undefined + onCleanup(() => wrappers.delete(key)) const record = (): void => { if (!wrapper) return - // Only record while the REAL row is mounted — a spacer's (or estimate's) - // height must never overwrite the exact measurement. - if (liveWin && liveWin.known.has(key) && !liveWin.mounted.has(key)) return const h = wrapper.height - if (h > 0) heights.set(key, h) + if (h <= 0) return + // Record the exact height only while the REAL row is mounted — a + // spacer's (estimate's) height must never overwrite the measurement. + if (mountedNow(key)) heights.set(key, h) + const prev = assumed.get(key) + assumed.set(key, h) + if (prev === undefined || prev === h) return + // ── S2 spacer correction (the zero-jank rule) ───────────────────── + // The row's laid-out height changed (estimate spacer → measured row, or + // a re-measure). onSizeChange fires inside the layout traversal, before + // paint. The scrollbox content's own onSizeChange (parent — runs FIRST) + // already re-pinned the bottom when sticky applies, so at-bottom needs + // no compensation here. Otherwise compensate scrollTop for changes + // fully ABOVE the viewport — same frame, zero visible movement. The + // legality check uses the row's PREVIOUS extent (what the user sees). + const sb = scroll() + if (!sb) return + const viewportHeight = sb.viewport.height + if (viewportHeight <= 0) return + const scrollTop = sb.scrollTop + const atBottom = scrollTop >= sb.scrollHeight - viewportHeight + if (atBottom) return // the sticky pin compensated already + const rowTop = wrapper.y - sb.content.y // content-space coordinates + const rowBottom = rowTop + prev + if (correctionIsLegal(rowTop, rowBottom, scrollTop, viewportHeight, atBottom) && rowBottom <= scrollTop) { + sb.scrollTop = scrollTop + (h - prev) + } + } + /** The real row, instrumented: the DEV mounted-row counters the headless + * tests assert against (and the bench can read — see windowRowStats). */ + const RealRow = () => { + noteRowMounted() + onCleanup(noteRowUnmounted) + return } return ( - (wrapper = el)} style={{ flexDirection: 'column', flexShrink: 0 }} onSizeChange={record}> + { + wrapper = el + wrappers.set(key, el) + }} + style={{ flexDirection: 'column', flexShrink: 0 }} + onSizeChange={record} + > } + fallback={} > - + )