hermes-agent/ui-opentui/src/test/transcriptWindow.test.tsx
alt-glitch c04aaecb51 opentui(v6): windowing S2 — pin selected rows instead of freezing on a lingering highlight
Adversarial-review follow-up to the S2 slice. The S1 rule froze ALL window
recomputes while renderer.getSelection()?.isActive — but a finished mouse
selection persists by design (boundary/renderer.ts keeps the highlight so
Ctrl+C can re-copy), so a long streaming turn behind a lingering highlight
ballooned the mounted set exactly like pre-windowing (and permanently
ratcheted the Yoga-WASM high-water).

Refinement:
- full freeze only while selection.isDragging (the native walk touches the
  live tree on every drag update — destroying a row mid-walk corrupts the
  highlight; unchanged from S1 where it matters),
- a finished highlight instead PINS the rows containing
  selection.selectedRenderables (parent-climb to the row wrapper via a
  WeakMap) as neverWindow — the highlight and a later Ctrl+C copy stay
  byte-exact while everything else keeps windowing,
- an active highlight counts as activity (no idle measure churn under it).

Test (headless mock-mouse drag): finished selection persists (isActive,
!isDragging) → 300-row burst keeps peakMounted < 120 AND
getSelectedText() returns the identical text afterward, the selected rows
having been pinned while long scrolled past the margin.

Verified on this build: gate digest otui-capped d5e9558583159eac… (2/2),
mem2000 otui-capped windowing-ON vmhwm 312MB (target ≤ 350), scroll2000
otui-capped p50 2.0ms / p99 6.0ms (gate ≤ 17ms). check exit 0 (648 tests).

Review verdicts on the remaining findings (verified against core source):
- "scrollTop compensation race": rejected — scrollTop is an imperative
  scrollbar property (no signal staleness); records fire in document order,
  each compensation immediately visible to the next.
- "heights map leak on /new": rejected — the countChanged cleanup prunes
  every per-key map against the live key set (test-verified).
- remount-in-viewport estimate shift: only reachable when one frame jumps
  past the margin (> 1 viewport); the design's accepted "remounted for
  view" path — documented in the header.
- expanded tool/reasoning re-collapse on far-remount: S1-accepted,
  deferred (component-local state; out of S2 file ownership).
2026-06-12 08:29:24 +05:30

352 lines
14 KiB
TypeScript

/**
* 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
* 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),
* 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, 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_KEYS = ['HERMES_TUI_WINDOWING', 'HERMES_TUI_WINDOW_IDLE_MS'] as const
const envBefore = ENV_KEYS.map(k => process.env[k])
afterEach(() => {
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). */
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.HERMES_TUI_WINDOWING = 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()
}
})
})
// ── 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 selection: drag freezes, a finished highlight only pins its rows', () => {
test('a persisting (finished) selection does not freeze windowing; its rows stay mounted for copy', async () => {
const store = seedRows(60)
const on = await mountTranscript(store, '1')
try {
// drag-select across a visible row's TEXT line (rows interleave with
// margin lines — find one from the frame), then release — the highlight
// PERSISTS by design (boundary/renderer.ts keeps it so Ctrl+C re-copies).
const textY = on.probe
.frame()
.split('\n')
.findIndex(line => line.includes('marker'))
expect(textY).toBeGreaterThanOrEqual(0)
await on.probe.mouse.drag(3, textY, 30, textY + 2)
await on.probe.settle()
const selection = on.probe.renderer.getSelection()
expect(selection?.isActive).toBe(true)
expect(selection?.isDragging).toBe(false)
const copied = selection?.getSelectedText() ?? ''
expect(copied).toContain('marker')
// burst 300 appends while the highlight lingers: windowing must keep
// adjudicating (the S1 full freeze would balloon the mounted set)…
resetWindowRowStats()
for (let i = 0; i < 300; i++) {
store.pushSystem(`late-${i} marker`)
if (i % 50 === 49) await on.probe.settle()
}
for (let i = 0; i < 6; i++) await on.probe.settle()
expect(windowRowStats().peakMounted).toBeLessThan(120)
// …while the selected row — long scrolled out past the margin — stays
// PINNED: the highlight's renderables are alive and Ctrl+C still copies
// the exact same text.
expect(on.probe.renderer.getSelection()?.getSelectedText()).toBe(copied)
} finally {
on.probe.destroy()
}
}, 60_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)
})