mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
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:
parent
2d7616121b
commit
eaa069e322
5 changed files with 721 additions and 70 deletions
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue