opentui(v6): transcript windowing S2 — append-time adjudication + windowed resume + edge measure

S2 of docs/plans/opentui-transcript-windowing.md (#27), behind
HERMES_TUI_WINDOWING (OFF path renders the byte-identical legacy tree).

Append-time adjudication: the window now recomputes on transcript GROWTH,
not just scroll — a createComputed on messages.length re-windows
synchronously per append, and while pinned at the bottom computeWindow
anchors to the cumulative content BOTTOM (pinnedBottom) instead of the
stale pre-layout scrollTop, so burst-appended rows are spacer-swapped the
moment they pass the margin. The frame driver additionally treats a
≥ ¼-viewport scrollHeight change (streaming growth) like scroll movement.
Unseen-row default changed from "always mounted" to "mounted iff created
streaming or within the bottom-30" — live rows still paint instantly with
zero added latency; a bulk commitSnapshot (resume) mounts ONLY the bottom
window and everything above starts as line-count-estimate spacers (chip-
and-spacing-aware estimateMessageHeight).

Spacer corrections (zero-jank rule): when a measure lands a height
different from what the spacer occupied, the wrapper's onSizeChange fires
inside the layout traversal, pre-paint. Pinned at bottom the scrollbox's
own sticky re-pin (content onSizeChange runs before the row wrappers')
already compensated — verified by test; otherwise scrollTop is compensated
same-frame for rows fully above the viewport (correctionIsLegal). Frames
stay byte-stable across corrections in both pinned and mid-history tests.

Lazy exact-measure (design §4 — the simple choice, documented): no true
offscreen layout exists in @opentui/core, so an idle pulse (no appends,
no scroll, no turn, no selection for HERMES_TUI_WINDOW_IDLE_MS≈1s) mounts
MEASURE_BATCH_ROWS=10 never-measured rows nearest the bottom window edge
(edgeMeasureBatch), records exact heights (incl. a direct post-layout pull
for rows whose mount changed nothing — no onSizeChange fires), and the
next recompute swaps them back to now-exact spacers. Scrolling itself
still measures the margin band.

DEV counter: windowRowStats (current/peak simultaneously-mounted rows),
exposed on globalThis behind HERMES_TUI_WINDOW_STATS; tests assert it.

Measured (this build, 39f9f433e+S2):
- check: exit 0 (647 tests / 39 files; +11 pure window cases, +4 headless)
- peak mounted: 31 rows over a 1500-row burst; 30 rows on a 600-row
  resume snapshot (bound asserted < 120)
- gate digest: otui-capped d5e9558583159eac… — byte-identical, 2/2 reps
- mem2000 (otui-capped, windowing ON, 8GB heap): vmhwm 300MB
  (S1 same-heap 518MB; S1 right-sized-heap 427MB; Ink 229-239MB;
  target ≤ 350MB)
- scroll2000 otui-capped: p50 2.0ms / p99 5.0ms / max 18ms
  (gate ≤ 17ms p99; S1 baseline p99 15ms)

Known S2 limits (deferred to S3, design §5): /compact·/details toggles and
width resizes leave out-of-window spacer heights stale until remount or
the idle march; expanded-body state above the window may re-collapse on
remount (S1-accepted).
This commit is contained in:
alt-glitch 2026-06-11 11:58:39 +05:30
parent 2d7616121b
commit eaa069e322
5 changed files with 721 additions and 70 deletions

View file

@ -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<typeof createSessionStore>
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<Mounted> {
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)
})