opentui(v6): transcript windowing S1 — exact-height spacers behind HERMES_TUI_WINDOWING

Core machinery of docs/plans/opentui-transcript-windowing.md (#27): rows
outside [scrollTop − viewport, scrollTop + 2·viewport) swap to an exact-height
empty <box> (1 yoga node, no text buffers / native handles), so the mounted
set stays ~3 viewports regardless of transcript length.

Flag: HERMES_TUI_WINDOWING — unset → ON; 0/false/no/off → OFF (envFlag
semantics, the bench A/B + one-env escape hatch). OFF renders the exact
legacy tree (no wrapper boxes).

Pieces:
- logic/window.ts (pure, table-tested): computeWindow (viewport ± 1-viewport
  margin intersection over cumulative exact heights; null heights fall back
  to a per-row line-count estimate), hysteresisFor/shouldRecompute (≥ ¼
  viewport between recomputes), correctionIsLegal (the jank rule: corrections
  only fully above the viewport with same-frame scrollTop compensation, or
  fully below it), estimateMessageHeight (line-count estimate; wrong values
  are fixed by remount only — S1 never corrects a spacer in place).
- view/transcript.tsx: per-row measuring wrapper records exact heights via
  onSizeChange (only while the real row is mounted); window driver is a
  renderer frame callback (setFrameCallback — scroll always renders, so no
  extra timer) publishing the mounted set through one signal + createSelector
  so only flipped rows re-render. Stable row keys via WeakMap<Message, n>
  (messages have no id; store proxies are reference-stable). Solid <Show>
  unmount destroys the row's renderables (@opentui/solid _removeNode →
  destroyRecursively).

Never-window rules:
- streaming rows (remount would restart native markdown streaming),
- the last row while a turn is running (deltas land there),
- the bottom 30 rows (fixed K — sticky-bottom region; rows under
  viewport+margin are mounted by the window calc anyway),
- rows the window has never adjudicated default to MOUNTED (new live rows
  paint instantly),
- the whole window FREEZES while a mouse selection is active
  (renderer.getSelection()?.isActive — a swap would destroy highlighted
  renderables under the native selection walk).

Tests: 30 pure window.test.ts cases + 2 headless integration cases
(transcriptWindow.test.tsx) pinning the zero-jank invariant (scrollHeight
identical ON vs OFF), the renderable shedding, and remount-on-scroll-back.
This commit is contained in:
alt-glitch 2026-06-11 11:04:53 +05:30
parent 8afb7bc570
commit 2d7616121b
4 changed files with 718 additions and 5 deletions

View file

@ -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<K> {
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<K> {
readonly rows: readonly WindowRow<K>[]
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<K> {
/** Row keys that must be mounted; everything else renders as a spacer. */
readonly mounted: ReadonlySet<K>
/** 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<K>(params: WindowParams<K>): WindowResult<K> {
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<K>()
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<Message, 'text' | 'parts'>,
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
}

View file

@ -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<typeof createSessionStore>
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<Mounted> {
process.env[ENV_KEY] = windowing
let root: Renderable | undefined
function Grab() {
root = useRenderer().root
return null
}
const probe = await renderProbe(
() => (
<ThemeProvider theme={() => store.state.theme}>
<Grab />
<Transcript store={store} />
</ThemeProvider>
),
{ 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()
}
})
})

View file

@ -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<number> {
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<number>[] {
return Array.from({ length: n }, (_, i) => row(i, h))
}
function mountedKeys(result: { mounted: ReadonlySet<number> }): 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<string>[] = [
{ 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<Message, 'text' | 'parts'> = {
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<Message, 'text' | 'parts'> = {
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<Message, 'text' | 'parts'> = {
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)
})
})

View file

@ -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 `<box height={recorded}/>`
* 1 yoga node, no text buffers, no native handles so the mounted set stays
* ~3 viewports of rows regardless of transcript length (the 671MBInk-parity
* memory fix). The Solid `<Show>` 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<number>
readonly known: ReadonlySet<number>
}
function sameSet(a: ReadonlySet<number>, b: ReadonlySet<number>): 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<ScrollBoxRenderable | undefined>()
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 (<For> 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<Message, number>()
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<number, number>()
const [winState, setWinState] = createSignal<WinState | undefined>(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 <Show>.
// 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<void> => {
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 (
<box ref={el => (wrapper = el)} style={{ flexDirection: 'column', flexShrink: 0 }} onSizeChange={record}>
<Show
when={isMounted(key)}
fallback={<box style={{ height: heights.get(key) ?? estimateFor(rowProps.message), flexShrink: 0 }} />}
>
<MessageLine message={rowProps.message} latest={rowProps.index() === latestAssistant()} />
</Show>
</box>
)
}
return (
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
@ -58,7 +236,13 @@ export function Transcript(props: { store: SessionStore }) {
</text>
</Show>
<For each={props.store.state.messages}>
{(message, i) => <MessageLine message={message} latest={i() === latestAssistant()} />}
{(message, i) =>
windowing ? (
<WindowedRow message={message} index={i} />
) : (
<MessageLine message={message} latest={i() === latestAssistant()} />
)
}
</For>
</DisplayProvider>
</ScrollAnchorProvider>