diff --git a/ui-opentui/src/logic/window.ts b/ui-opentui/src/logic/window.ts new file mode 100644 index 00000000000..3a9056b969d --- /dev/null +++ b/ui-opentui/src/logic/window.ts @@ -0,0 +1,166 @@ +/** + * window — pure transcript-windowing math (slice S1 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 + * rows regardless of transcript length. This module is the testable core: + * + * - `computeWindow` — which row keys must be mounted for a given scrollTop: + * 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). + * - `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 + * thrash at window edges. + * - `correctionIsLegal` — the jank rule for spacer-height corrections: + * a correction may only touch rows fully ABOVE the viewport (the caller + * compensates scrollTop in the same frame — automatic when bottom-anchored + * 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. + */ +import type { Message, Part } from './store.ts' + +/** One transcript row as the window calc sees it. */ +export interface WindowRow { + readonly key: K + /** Exact recorded height (the row wrapper's last onSizeChange measurement, + * 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 + /** Always mounted regardless of the window (streaming/live rows — a remount + * would restart native markdown streaming). */ + readonly neverWindow: boolean +} + +export interface WindowParams { + readonly rows: readonly WindowRow[] + readonly scrollTop: number + readonly viewportHeight: number + /** Mounted band kept above/below the viewport (design: 1 viewport each side). */ + readonly margin: number + /** Stand-in height for null-height rows without their own estimate. */ + readonly fallbackHeight?: number + /** The bottom K rows are always mounted (sticky-bottom region). */ + readonly bottomK?: number +} + +export interface WindowResult { + /** Row keys that must be mounted; everything else renders as a spacer. */ + readonly mounted: ReadonlySet + /** The scrollTop this window was computed at — the next hysteresis anchor. */ + readonly anchor: number +} + +/** Default stand-in for a null-height row with no estimate (≈ a short row). */ +export const DEFAULT_FALLBACK_HEIGHT = 2 + +/** Ceiling on a single row's line-count estimate — a pathological wall of text + * must not make the never-mounted region look kilometers tall. */ +const ESTIMATE_MAX_LINES = 500 + +/** Hysteresis for the window recompute: ≥ ¼ viewport (design rule), never 0. */ +export function hysteresisFor(viewportHeight: number): number { + return Math.max(1, Math.ceil(viewportHeight / 4)) +} + +/** Whether scrollTop has moved far enough from the last computation anchor to + * justify a new window (no anchor yet → always). */ +export function shouldRecompute(scrollTop: number, anchor: number | null, hysteresis: number): boolean { + if (anchor === null) return true + return Math.abs(scrollTop - anchor) >= hysteresis +} + +/** Compute the set of row keys that must be mounted for this scroll position. */ +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 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 + // 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 } +} + +/** + * The jank rule: may a spacer-height correction for the row spanning + * [rowTop, rowBottom) be applied at this scroll position without visibly + * moving content? + * + * - Fully BELOW the viewport → legal (invisible by definition). + * - Fully ABOVE the viewport → legal, PROVIDED the caller compensates + * scrollTop by the height delta in the same frame. When `atBottom` + * (sticky-bottom pinned) the pin performs that compensation automatically + * (bottom-anchored ⇒ zero visual movement); legality is the same either + * way — the flag documents which side owes the compensation. + * - Intersecting the viewport → forbidden; defer until the row scrolls out + * or is remounted for view. + */ +export function correctionIsLegal( + rowTop: number, + rowBottom: number, + scrollTop: number, + viewportHeight: number, + _atBottom: boolean +): boolean { + if (rowTop >= scrollTop + viewportHeight) return true // fully below the viewport + if (rowBottom <= scrollTop) return true // fully above — compensate scrollTop in the same frame + return false +} + +/** Rendered line count of a text block (1-based; empty text still occupies a row). */ +function lineCount(text: string): number { + if (!text) return 1 + let lines = 1 + for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) lines++ + return lines +} + +/** 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, '')) + 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 + * — 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). + */ +export function estimateMessageHeight( + message: Pick, + spacing: { readonly top: number; readonly bottom: number }, + gap: number +): 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) + } else { + content = lineCount(message.text) + } + 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 new file mode 100644 index 00000000000..d2240e10be0 --- /dev/null +++ b/ui-opentui/src/test/transcriptWindow.test.tsx @@ -0,0 +1,126 @@ +/** + * Transcript windowing S1 — 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 + * than the unwindowed tree — the spacer is 1 box, the row was ~3+ texts), + * - 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). + */ +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 { 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] +afterEach(() => { + if (envBefore === undefined) delete process.env[ENV_KEY] + else process.env[ENV_KEY] = envBefore +}) + +/** Seed `n` settled one-line system rows (flat text — no async markdown). */ +function seedRows(n: number): Store { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + for (let i = 0; i < n; i++) store.pushSystem(`row-${i} marker`) + return store +} + +function walk(node: Renderable, visit: (n: Renderable) => void): void { + visit(node) + for (const child of node.getChildren()) walk(child, visit) +} + +interface Mounted { + probe: RenderProbe + count: () => number + scrollbox: () => ScrollBoxRenderable +} + +async function mountTranscript(store: Store, windowing: '1' | '0'): Promise { + process.env[ENV_KEY] = windowing + let root: Renderable | undefined + function Grab() { + root = useRenderer().root + return null + } + const probe = await renderProbe( + () => ( + store.state.theme}> + + + + ), + { width: 50, height: 12 } + ) + // several passes: layout (heights recorded) → frame tick (window computed) + // → swap render — the driver is the renderer frame callback. + for (let i = 0; i < 6; i++) await probe.settle() + const count = () => { + let n = 0 + if (root) walk(root, () => n++) + return n + } + const scrollbox = () => { + let sb: ScrollBoxRenderable | undefined + if (root) { + walk(root, node => { + if (node instanceof ScrollBoxRenderable) sb ??= node + }) + } + if (!sb) throw new Error('no scrollbox in the mounted tree') + return sb + } + return { probe, count, scrollbox } +} + +const ROWS = 120 + +describe('transcript windowing (HERMES_TUI_WINDOWING) — S1 machinery', () => { + test('out-of-window rows unmount into exact-height spacers; OFF keeps the full tree', async () => { + const on = await mountTranscript(seedRows(ROWS), '1') + const off = await mountTranscript(seedRows(ROWS), '0') + try { + // sticky-bottom: both variants sit pinned at the bottom showing the tail. + expect(on.probe.frame()).toContain(`row-${ROWS - 1} marker`) + expect(off.probe.frame()).toContain(`row-${ROWS - 1} marker`) + + // ZERO-JANK INVARIANT: spacers are exact-height — the windowed content + // is precisely as tall as the fully-mounted content. + expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight) + expect(on.scrollbox().scrollHeight).toBeGreaterThan(100) // sanity: way past the viewport + + // The window actually sheds renderables: ~viewport±margin + bottom-30 + // stay mounted out of 120 rows; the rest are 1-box spacers. The legacy + // tree keeps every row's text renderables alive. + expect(on.count()).toBeLessThan(off.count() * 0.6) + } finally { + on.probe.destroy() + off.probe.destroy() + } + }) + + test('scrolling far from the bottom remounts spaced-out rows (and the frame paints them)', async () => { + const on = await mountTranscript(seedRows(ROWS), '1') + try { + const sb = on.scrollbox() + // row-0 is far outside the bottom window: not painted while pinned. + expect(on.probe.frame()).not.toContain('row-0 marker') + sb.scrollTo(0) + for (let i = 0; i < 6; i++) await on.probe.settle() + expect(sb.scrollTop).toBe(0) + expect(on.probe.frame()).toContain('row-0 marker') + } finally { + on.probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/test/window.test.ts b/ui-opentui/src/test/window.test.ts new file mode 100644 index 00000000000..a6b033a8b89 --- /dev/null +++ b/ui-opentui/src/test/window.test.ts @@ -0,0 +1,237 @@ +/** + * window.ts — pure transcript-windowing math (design: docs/plans/ + * opentui-transcript-windowing.md, slice S1). 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. + */ +import { describe, expect, test } from 'vitest' + +import type { Message } from '../logic/store.ts' +import { + computeWindow, + correctionIsLegal, + estimateMessageHeight, + hysteresisFor, + shouldRecompute, + type WindowRow +} from '../logic/window.ts' + +function row( + key: number, + height: number | null, + opts?: { neverWindow?: boolean; estimate?: number } +): WindowRow { + const base = { key, height, neverWindow: opts?.neverWindow ?? false } + return opts?.estimate === undefined ? base : { ...base, estimate: opts.estimate } +} + +/** n rows of uniform height h, keyed 0..n-1 (row i spans [i*h, (i+1)*h)). */ +function uniform(n: number, h: number): WindowRow[] { + return Array.from({ length: n }, (_, i) => row(i, h)) +} + +function mountedKeys(result: { mounted: ReadonlySet }): number[] { + return [...result.mounted].sort((a, b) => a - b) +} + +describe('hysteresisFor', () => { + test('≥ ¼ viewport, rounded up', () => { + expect(hysteresisFor(40)).toBe(10) + expect(hysteresisFor(5)).toBe(2) + expect(hysteresisFor(4)).toBe(1) + }) + + test('never below 1 row (degenerate viewports)', () => { + expect(hysteresisFor(0)).toBe(1) + expect(hysteresisFor(2)).toBe(1) + }) +}) + +describe('shouldRecompute', () => { + test('no prior anchor → always recompute', () => { + expect(shouldRecompute(0, null, 10)).toBe(true) + expect(shouldRecompute(500, null, 10)).toBe(true) + }) + + test('movement below hysteresis → keep the current window', () => { + expect(shouldRecompute(108, 100, 10)).toBe(false) + expect(shouldRecompute(92, 100, 10)).toBe(false) + expect(shouldRecompute(100, 100, 10)).toBe(false) + }) + + test('movement at/above hysteresis (either direction) → recompute', () => { + expect(shouldRecompute(110, 100, 10)).toBe(true) + expect(shouldRecompute(90, 100, 10)).toBe(true) + expect(shouldRecompute(250, 100, 10)).toBe(true) + }) +}) + +describe('computeWindow — viewport ± margin intersection', () => { + // 100 rows × 10 → content height 1000. Viewport 40, margin 40 (1 viewport), + // scrollTop 480 → window [440, 560). Row i spans [10i, 10i+10). + const base = { viewportHeight: 40, margin: 40, scrollTop: 480 } + + test('mounts exactly the rows intersecting [scrollTop − margin, scrollTop + viewport + margin)', () => { + const result = computeWindow({ rows: uniform(100, 10), ...base }) + expect(mountedKeys(result)).toEqual([44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55]) + }) + + test('rows merely TOUCHING the window edge are not mounted', () => { + const result = computeWindow({ rows: uniform(100, 10), ...base }) + // row 43 spans [430, 440) — its bottom touches windowStart 440: out. + expect(result.mounted.has(43)).toBe(false) + // row 56 spans [560, 570) — its top touches windowEnd 560: out. + expect(result.mounted.has(56)).toBe(false) + }) + + test('anchor echoes the scrollTop the window was computed at', () => { + expect(computeWindow({ rows: uniform(100, 10), ...base }).anchor).toBe(480) + expect(computeWindow({ rows: [], scrollTop: 7, viewportHeight: 40, margin: 40 }).anchor).toBe(7) + }) + + test('scrolled to the top: window clamps naturally (no negative-row weirdness)', () => { + const result = computeWindow({ rows: uniform(100, 10), scrollTop: 0, viewportHeight: 40, margin: 40 }) + // window [-40, 80) → rows 0..7 + expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + }) + + test('empty transcript → empty window', () => { + const result = computeWindow({ rows: [], scrollTop: 0, viewportHeight: 40, margin: 40 }) + expect(result.mounted.size).toBe(0) + }) + + test('everything fits in the window → everything mounted', () => { + const result = computeWindow({ rows: uniform(5, 2), scrollTop: 0, viewportHeight: 40, margin: 40 }) + expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4]) + }) + + test('works with non-numeric keys (generic)', () => { + const rows: WindowRow[] = [ + { key: 'a', height: 50, neverWindow: false }, + { key: 'b', height: 50, neverWindow: false }, + { key: 'c', height: 50, neverWindow: false } + ] + const result = computeWindow({ rows, scrollTop: 60, viewportHeight: 30, margin: 0 }) + // window [60, 90) → only 'b' ([50, 100)) intersects + expect([...result.mounted]).toEqual(['b']) + }) +}) + +describe('computeWindow — never-window and bottom-K rules', () => { + test('neverWindow rows stay mounted however far outside the window', () => { + const rows = uniform(100, 10) + rows[90] = row(90, 10, { neverWindow: true }) + const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 40, margin: 40 }) + expect(result.mounted.has(90)).toBe(true) + expect(result.mounted.has(89)).toBe(false) + }) + + test('the bottom K rows are always mounted (sticky-bottom region)', () => { + const result = computeWindow({ rows: uniform(100, 10), scrollTop: 0, viewportHeight: 40, margin: 40, bottomK: 5 }) + expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 95, 96, 97, 98, 99]) + }) + + test('bottomK larger than the transcript mounts everything', () => { + const result = computeWindow({ rows: uniform(10, 10), scrollTop: 0, viewportHeight: 4, margin: 0, bottomK: 50 }) + expect(result.mounted.size).toBe(10) + }) +}) + +describe('computeWindow — null heights use the estimate', () => { + test('a per-row estimate stands in for a never-measured height (and shifts later offsets)', () => { + // row 0 estimated at 100 → row 1 starts at 100; window [100, 110) hits only row 1. + const rows = [row(0, null, { estimate: 100 }), row(1, 10)] + const result = computeWindow({ rows, scrollTop: 100, viewportHeight: 10, margin: 0 }) + expect(mountedKeys(result)).toEqual([1]) + }) + + test('a recorded height wins over the estimate', () => { + const rows = [row(0, 10, { estimate: 100 }), row(1, 10)] + const result = computeWindow({ rows, scrollTop: 100, viewportHeight: 10, margin: 0 }) + // row 0 is REALLY 10 tall → row 1 spans [10, 20): nothing in [100, 110). + expect(result.mounted.size).toBe(0) + }) + + test('null height with no estimate falls back to fallbackHeight', () => { + const rows = [row(0, null), row(1, 10)] + const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 10, margin: 0, fallbackHeight: 100 }) + // row 0 assumed [0, 100) → mounted; row 1 [100, 110) → out of [0, 10). + expect(mountedKeys(result)).toEqual([0]) + }) +}) + +describe('correctionIsLegal — the jank rule', () => { + // viewport shows [100, 140) + const scrollTop = 100 + const viewportHeight = 40 + + test.each([true, false])('fully ABOVE the viewport is legal (compensation applies) — atBottom=%s', atBottom => { + expect(correctionIsLegal(20, 60, scrollTop, viewportHeight, atBottom)).toBe(true) + // boundary: row bottom touching the viewport top is still fully above + expect(correctionIsLegal(50, 100, scrollTop, viewportHeight, atBottom)).toBe(true) + }) + + test.each([true, false])('fully BELOW the viewport is legal (invisible) — atBottom=%s', atBottom => { + expect(correctionIsLegal(150, 170, scrollTop, viewportHeight, atBottom)).toBe(true) + // boundary: row top touching the viewport bottom is still fully below + expect(correctionIsLegal(140, 160, scrollTop, viewportHeight, atBottom)).toBe(true) + }) + + test.each([true, false])('any intersection with the viewport is FORBIDDEN — atBottom=%s', atBottom => { + expect(correctionIsLegal(90, 110, scrollTop, viewportHeight, atBottom)).toBe(false) // clips the top edge + expect(correctionIsLegal(130, 150, scrollTop, viewportHeight, atBottom)).toBe(false) // clips the bottom edge + expect(correctionIsLegal(110, 120, scrollTop, viewportHeight, atBottom)).toBe(false) // inside + expect(correctionIsLegal(90, 150, scrollTop, viewportHeight, atBottom)).toBe(false) // spans the whole viewport + }) +}) + +describe('estimateMessageHeight — line-count estimate for never-mounted rows', () => { + const spacing = { top: 2, bottom: 1 } + + test('flat row: newline count + turn spacing', () => { + expect(estimateMessageHeight({ text: 'hello' }, spacing, 1)).toBe(1 + 3) + expect(estimateMessageHeight({ text: 'a\nb\nc' }, spacing, 1)).toBe(3 + 3) + }) + + test('empty text still occupies at least one row', () => { + expect(estimateMessageHeight({ text: '' }, { top: 0, bottom: 0 }, 0)).toBe(1) + }) + + test('parts row: text lines + 1 per collapsed tool/reasoning + inter-part gaps', () => { + const message: Pick = { + text: '', + parts: [ + { type: 'text', id: 'p1', text: 'line1\nline2' }, + { type: 'tool', id: 't1', name: 'terminal', state: 'complete' }, + { type: 'reasoning', id: 'p2', text: 'thought\nover\nlines' } + ] + } + // 2 (text) + 1 (tool header) + 1 (collapsed reasoning) + 2 gaps + 3 spacing + expect(estimateMessageHeight(message, spacing, 1)).toBe(2 + 1 + 1 + 2 + 3) + }) + + test('text parts strip leading/trailing blank lines (the view does the same)', () => { + const message: Pick = { + text: '', + parts: [{ type: 'text', id: 'p1', text: '\n\nhello\n' }] + } + expect(estimateMessageHeight(message, { top: 0, bottom: 0 }, 1)).toBe(1) + }) + + test('compact mode (gap 0, no margins) collapses the chrome', () => { + const message: Pick = { + text: '', + parts: [ + { type: 'text', id: 'p1', text: 'one' }, + { type: 'tool', id: 't1', name: 'terminal', state: 'complete' } + ] + } + expect(estimateMessageHeight(message, { top: 0, bottom: 0 }, 0)).toBe(2) + }) + + test('a pathological wall of text is clamped', () => { + const text = Array.from({ length: 10_000 }, (_, i) => `l${i}`).join('\n') + expect(estimateMessageHeight({ text }, { top: 0, bottom: 0 }, 0)).toBeLessThanOrEqual(500) + }) +}) diff --git a/ui-opentui/src/view/transcript.tsx b/ui-opentui/src/view/transcript.tsx index 15dbc3efee4..d837408765c 100644 --- a/ui-opentui/src/view/transcript.tsx +++ b/ui-opentui/src/view/transcript.tsx @@ -13,20 +13,76 @@ * * 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) ────── + * 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 + + * 2·viewport) swap to an EXACT-HEIGHT empty spacer `` + * — 1 yoga node, no text buffers, no native handles — so the mounted set stays + * ~3 viewports of rows regardless of transcript length (the 671MB→Ink-parity + * 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). */ -import type { ScrollBoxRenderable } from '@opentui/core' -import { createMemo, createSignal, For, Show } from 'solid-js' +import type { BoxRenderable, ScrollBoxRenderable } from '@opentui/core' +import { useRenderer } from '@opentui/solid' +import { createMemo, createSelector, createSignal, For, onCleanup, onMount, Show } from 'solid-js' -import type { SessionStore } from '../logic/store.ts' +import { envFlag } from '../logic/env.ts' +import type { Message, SessionStore } from '../logic/store.ts' +import { computeWindow, estimateMessageHeight, hysteresisFor, shouldRecompute } from '../logic/window.ts' import { DisplayProvider } from './display.tsx' import { HomeHint } from './homeHint.tsx' -import { MessageLine } from './messageLine.tsx' +import { MessageLine, turnSpacing } from './messageLine.tsx' import { ScrollAnchorProvider } from './scrollAnchor.tsx' import { useTheme } from './theme.tsx' +/** + * The bottom K rows are ALWAYS mounted (the sticky-bottom region the user + * lives in; also the zone where swap turbulence would be most visible). 30 is + * a fixed, documented pick (the design's alternative — ceil(viewport/avg-row) + * — buys little: rows under the viewport+margin are mounted by the window calc + * anyway, so K only backstops sticky re-pins and burst appends). + */ +const BOTTOM_ALWAYS_MOUNTED = 30 + +/** The published window state: which keys are mounted, and which keys the + * computation has SEEN (unseen keys default to mounted — see isMounted). */ +interface WinState { + readonly mounted: ReadonlySet + readonly known: ReadonlySet +} + +function sameSet(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false + for (const k of a) if (!b.has(k)) return false + return true +} + +/** Signal equality for WinState — identical sets must not re-notify selectors. */ +function sameWinState(a: WinState | undefined, b: WinState | undefined): boolean { + if (!a || !b) return a === b + return sameSet(a.mounted, b.mounted) && sameSet(a.known, b.known) +} + export function Transcript(props: { store: SessionStore }) { const [scroll, setScroll] = createSignal() const theme = useTheme() + const renderer = useRenderer() const dropped = () => props.store.state.dropped const sid = () => props.store.state.sessionId // The NEWEST assistant answer's index — gold is earned (design pass): only @@ -38,6 +94,128 @@ export function Transcript(props: { store: SessionStore }) { } return -1 }) + + // ── windowing state (S1) ─────────────────────────────────────────────── + // Read once per transcript: the flag is an A/B + escape hatch, not live config. + const windowing = envFlag(process.env.HERMES_TUI_WINDOWING, true) + // Stable row keys: messages carry no id and the store relies on reference + // identity ( keys by item reference; solid-js/store proxies are cached + // per underlying object, so the reference is stable across reads/mutations). + // A WeakMap-assigned monotonic number gives the window math a primitive key + // without restructuring the store or mutating Message objects. + const rowKeys = new WeakMap() + let rowSeq = 0 + const keyOf = (message: Message): number => { + let key = rowKeys.get(message) + if (key === undefined) { + key = ++rowSeq + rowKeys.set(message, key) + } + return key + } + // key → last exact height measured while the REAL row was mounted (the + // 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() + 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 + // 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) + }) + const estimateFor = (message: Message): number => { + const compact = props.store.state.compact + return estimateMessageHeight(message, turnSpacing(message.role, compact), compact ? 0 : 1) + } + + // ── window driver: per-frame scrollTop check (no scroll signal exists; the + // frame callback fires on every rendered frame, and scrolling always renders). + let anchor: number | null = null + let lastCount = -1 + const tick = (): 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 + const viewportHeight = sb.viewport.height + if (viewportHeight <= 0) return + const messages = props.store.state.messages + const scrollTop = sb.scrollTop + const countChanged = messages.length !== lastCount + if (!countChanged && !shouldRecompute(scrollTop, anchor, hysteresisFor(viewportHeight))) return + const running = props.store.state.info.running ?? false + const rows = messages.map((message, i) => { + const key = keyOf(message) + return { + key, + height: heights.get(key) ?? null, + estimate: estimateFor(message), + // 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 + // here (the override lives in component-local signals — toolPart.tsx/ + // reasoningPart.tsx); expanded rows far above the viewport may + // re-collapse on remount. Accepted for S1. + neverWindow: (message.streaming ?? false) || (running && i === messages.length - 1) + } + }) + const result = computeWindow({ + rows, + scrollTop, + viewportHeight, + margin: viewportHeight, // 1 viewport each side (design §Mechanism 1) + bottomK: BOTTOM_ALWAYS_MOUNTED + }) + anchor = result.anchor + lastCount = messages.length + 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 } + setWinState(liveWin) + } + onMount(() => { + if (!windowing) return + const frame = (_deltaTime: number): Promise => { + tick() + return Promise.resolve() + } + renderer.setFrameCallback(frame) + onCleanup(() => renderer.removeFrameCallback(frame)) + }) + + /** 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) + let wrapper: BoxRenderable | undefined + 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) + } + return ( + (wrapper = el)} style={{ flexDirection: 'column', flexShrink: 0 }} onSizeChange={record}> + } + > + + + + ) + } + return ( @@ -58,7 +236,13 @@ export function Transcript(props: { store: SessionStore }) { - {(message, i) => } + {(message, i) => + windowing ? ( + + ) : ( + + ) + }