mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +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
|
|
@ -990,6 +990,11 @@ export function createSessionStore(options?: SessionStoreOptions) {
|
|||
// briefly hands the full fetched history to <For>. Pre-slicing guarantees
|
||||
// resuming ANY session mounts at most MESSAGE_CAP rows. (Events buffered
|
||||
// across the resume RPC, replayed below, self-cap via capMessages per push.)
|
||||
// With windowing ON (HERMES_TUI_WINDOWING — view/transcript.tsx S2) the
|
||||
// view mounts only the BOTTOM window of this snapshot anyway: rows created
|
||||
// deep in a bulk replace start as line-count-estimate spacers and are
|
||||
// measured lazily. The pre-slice still bounds the windowing-OFF path and
|
||||
// the store's own JS retention.
|
||||
const capped = snapshot.length > MESSAGE_CAP ? snapshot.slice(-MESSAGE_CAP) : snapshot
|
||||
setState('messages', capped)
|
||||
// A resume is a fresh view → SET (not accumulate) the dropped count to what the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* window — pure transcript-windowing math (slice S1 of docs/plans/
|
||||
* window — pure transcript-windowing math (slices S1+S2 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
|
||||
|
|
@ -9,7 +9,12 @@
|
|||
* 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).
|
||||
* (streaming/live) and the bottom K rows (sticky-bottom region). With
|
||||
* `pinnedBottom` (S2) the window anchors to the BOTTOM of the content
|
||||
* instead of `scrollTop`: during burst appends / a resume snapshot the
|
||||
* sticky pin will land at the new bottom, but layout (and therefore
|
||||
* scrollTop) lags the store — anchoring to the cumulative content bottom
|
||||
* adjudicates appended rows immediately instead of one frame late.
|
||||
* - `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
|
||||
|
|
@ -20,9 +25,20 @@
|
|||
* 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.
|
||||
* have never been measured (resume history above the viewport). A wrong
|
||||
* estimate is fixed by remount (scrolling near) or by the S2 idle measure
|
||||
* pass, both governed by the jank rule.
|
||||
* - `edgeMeasureBatch` (S2 — design §4, the SIMPLE choice): @opentui/core
|
||||
* cannot lay a renderable out without parenting it into the live tree
|
||||
* (layout is the tree's Yoga pass), so true offscreen measurement isn't
|
||||
* available. Instead the idle pass mounts a small batch of never-measured
|
||||
* rows nearest the bottom window edge — they are the next to be seen when
|
||||
* the user scrolls back — records their exact heights, and lets the next
|
||||
* window recompute swap them back to (now exact) spacers. Estimates far
|
||||
* from the window stay estimates until the march reaches them.
|
||||
* - `windowRowStats` — a DEV counter (current / peak simultaneously-mounted
|
||||
* real rows) the integration tests assert against and the bench can read
|
||||
* (transcript.tsx exposes it on globalThis behind HERMES_TUI_WINDOW_STATS).
|
||||
*/
|
||||
import type { Message, Part } from './store.ts'
|
||||
|
||||
|
|
@ -33,7 +49,7 @@ export interface WindowRow<K> {
|
|||
* 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
|
||||
readonly estimate?: number | undefined
|
||||
/** Always mounted regardless of the window (streaming/live rows — a remount
|
||||
* would restart native markdown streaming). */
|
||||
readonly neverWindow: boolean
|
||||
|
|
@ -49,6 +65,14 @@ export interface WindowParams<K> {
|
|||
readonly fallbackHeight?: number
|
||||
/** The bottom K rows are always mounted (sticky-bottom region). */
|
||||
readonly bottomK?: number
|
||||
/** Anchor the window to the BOTTOM of the cumulative content instead of
|
||||
* `scrollTop` (S2 append-time adjudication): while the view is pinned to
|
||||
* the bottom, appended rows extend the content BELOW the last laid-out
|
||||
* scrollTop — the sticky pin only catches up at the next layout pass.
|
||||
* Anchoring to the content bottom adjudicates those rows immediately
|
||||
* (new in-window rows mount, rows pushed past the margin become spacers)
|
||||
* without waiting a frame. */
|
||||
readonly pinnedBottom?: boolean
|
||||
}
|
||||
|
||||
export interface WindowResult<K> {
|
||||
|
|
@ -81,22 +105,91 @@ export function shouldRecompute(scrollTop: number, anchor: number | null, hyster
|
|||
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 heightOf = (r: WindowRow<K>): number => r.height ?? r.estimate ?? fallback
|
||||
// pinnedBottom: the effective scrollTop is where the sticky pin will land —
|
||||
// the cumulative content bottom minus one viewport (clamped at 0).
|
||||
let effectiveTop = params.scrollTop
|
||||
if (params.pinnedBottom) {
|
||||
let contentHeight = 0
|
||||
for (const r of params.rows) contentHeight += heightOf(r)
|
||||
effectiveTop = Math.max(0, contentHeight - params.viewportHeight)
|
||||
}
|
||||
const windowStart = effectiveTop - params.margin
|
||||
const windowEnd = effectiveTop + 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
|
||||
const bottom = top + heightOf(r)
|
||||
// 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 }
|
||||
return { mounted, anchor: effectiveTop }
|
||||
}
|
||||
|
||||
/** Rows the S2 idle measure pass should mount next: up to `batch` never-
|
||||
* measured, not-currently-mounted, windowable rows, NEAREST THE BOTTOM first
|
||||
* (the bottom window edge is where a scroll-back enters history, so these are
|
||||
* the next rows to be seen; the march then proceeds upward over idle pulses).
|
||||
* Never-window rows are excluded — they are always mounted anyway. */
|
||||
export function edgeMeasureBatch<K>(rows: readonly WindowRow<K>[], mounted: ReadonlySet<K>, batch: number): K[] {
|
||||
const out: K[] = []
|
||||
for (let i = rows.length - 1; i >= 0 && out.length < batch; i--) {
|
||||
const r = rows[i]
|
||||
if (!r || r.height !== null || r.neverWindow || mounted.has(r.key)) continue
|
||||
out.push(r.key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Default idle delay before a lazy measure pulse (design §4): no appends, no
|
||||
* scroll movement, no running turn for this long → mount one small batch. */
|
||||
export const DEFAULT_MEASURE_IDLE_MS = 1000
|
||||
|
||||
/** Parse `HERMES_TUI_WINDOW_IDLE_MS` (TUI-only DEV/test knob): the idle delay
|
||||
* before a lazy measure pulse. A non-negative integer → that delay (0 = pulse
|
||||
* on every idle frame — the headless tests use this to make pulses
|
||||
* deterministic); unset/garbage → DEFAULT_MEASURE_IDLE_MS. */
|
||||
export function measureIdleDelayMs(value: string | undefined): number {
|
||||
const v = value?.trim() ?? ''
|
||||
if (!/^\d+$/.test(v)) return DEFAULT_MEASURE_IDLE_MS
|
||||
return Number.parseInt(v, 10)
|
||||
}
|
||||
|
||||
// ── DEV counter: simultaneously-mounted real rows (current + peak) ────────
|
||||
// Two ints, always maintained (the cost is negligible); the integration tests
|
||||
// assert `peakMounted` stays bounded during bursts/resume, and transcript.tsx
|
||||
// exposes the live object on globalThis when HERMES_TUI_WINDOW_STATS is set so
|
||||
// the bench can sample it. One transcript per process in practice; tests that
|
||||
// mount several reset between phases.
|
||||
export interface WindowRowStats {
|
||||
mounted: number
|
||||
peakMounted: number
|
||||
}
|
||||
|
||||
const rowStats: WindowRowStats = { mounted: 0, peakMounted: 0 }
|
||||
|
||||
/** The live stats object (mutated in place — safe to hold a reference). */
|
||||
export function windowRowStats(): Readonly<WindowRowStats> {
|
||||
return rowStats
|
||||
}
|
||||
|
||||
export function noteRowMounted(): void {
|
||||
rowStats.mounted++
|
||||
if (rowStats.mounted > rowStats.peakMounted) rowStats.peakMounted = rowStats.mounted
|
||||
}
|
||||
|
||||
export function noteRowUnmounted(): void {
|
||||
rowStats.mounted--
|
||||
}
|
||||
|
||||
/** Reset the peak to the CURRENT mounted count (rows still live stay counted). */
|
||||
export function resetWindowRowStats(): void {
|
||||
rowStats.peakMounted = rowStats.mounted
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -134,33 +227,37 @@ function lineCount(text: string): number {
|
|||
}
|
||||
|
||||
/** 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, ''))
|
||||
* leading/trailing blanks — mirror that) + the settled block's `⧉ copy` chip
|
||||
* line when `chips`; tool/reasoning → 1 collapsed header line (the default
|
||||
* render for settled, never-mounted history). */
|
||||
function partLines(part: Part, chips: boolean): number {
|
||||
if (part.type === 'text') return lineCount(part.text.replace(/^\n+|\n+$/g, '')) + (chips ? 1 : 0)
|
||||
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
|
||||
* (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).
|
||||
* blank line (0 in /compact); `chips` mirrors the view's per-block `⧉ copy`
|
||||
* line (settled non-system rows outside /compact — messageLine.tsx CopyChip).
|
||||
*/
|
||||
export function estimateMessageHeight(
|
||||
message: Pick<Message, 'text' | 'parts'>,
|
||||
message: Pick<Message, 'text' | 'parts'> & { readonly role?: Message['role'] },
|
||||
spacing: { readonly top: number; readonly bottom: number },
|
||||
gap: number
|
||||
gap: number,
|
||||
chips = false
|
||||
): 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)
|
||||
for (const part of parts) content += partLines(part, chips)
|
||||
} else {
|
||||
content = lineCount(message.text)
|
||||
if (chips && message.role !== undefined && message.role !== 'system' && message.text.trim()) content += 1
|
||||
}
|
||||
return Math.min(ESTIMATE_MAX_LINES, Math.max(1, content)) + spacing.top + spacing.bottom
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
/**
|
||||
* window.ts — pure transcript-windowing math (design: docs/plans/
|
||||
* opentui-transcript-windowing.md, slice S1). Table-tests the window calc
|
||||
* opentui-transcript-windowing.md, slices S1+S2). 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.
|
||||
* null-height estimate fallback, the correction-legality jank rule, the S2
|
||||
* pinned-bottom (append-time) anchoring, the idle edge-measure batch picker,
|
||||
* the idle-delay knob, and the DEV mounted-row counters.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
|
|
@ -11,9 +13,16 @@ import type { Message } from '../logic/store.ts'
|
|||
import {
|
||||
computeWindow,
|
||||
correctionIsLegal,
|
||||
DEFAULT_MEASURE_IDLE_MS,
|
||||
edgeMeasureBatch,
|
||||
estimateMessageHeight,
|
||||
hysteresisFor,
|
||||
measureIdleDelayMs,
|
||||
noteRowMounted,
|
||||
noteRowUnmounted,
|
||||
resetWindowRowStats,
|
||||
shouldRecompute,
|
||||
windowRowStats,
|
||||
type WindowRow
|
||||
} from '../logic/window.ts'
|
||||
|
||||
|
|
@ -118,6 +127,68 @@ describe('computeWindow — viewport ± margin intersection', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('computeWindow — pinnedBottom (S2 append-time anchoring)', () => {
|
||||
test('anchors the window to the content BOTTOM regardless of a stale scrollTop', () => {
|
||||
// 100 rows × 10 → content 1000; viewport 40, margin 40. scrollTop is a
|
||||
// STALE 0 (layout lagging a burst append), but pinnedBottom anchors to
|
||||
// 1000 − 40 = 960 → window [920, 1040) → rows 92..99.
|
||||
const result = computeWindow({
|
||||
rows: uniform(100, 10),
|
||||
scrollTop: 0,
|
||||
viewportHeight: 40,
|
||||
margin: 40,
|
||||
pinnedBottom: true
|
||||
})
|
||||
expect(mountedKeys(result)).toEqual([92, 93, 94, 95, 96, 97, 98, 99])
|
||||
expect(result.anchor).toBe(960)
|
||||
})
|
||||
|
||||
test('appended rows past the margin become spacers without any scroll movement', () => {
|
||||
// The append-time rule: grow 100 → 200 rows at the same stale scrollTop —
|
||||
// the window slides to the NEW bottom; the old bottom rows fall out.
|
||||
const before = computeWindow({
|
||||
rows: uniform(100, 10),
|
||||
scrollTop: 0,
|
||||
viewportHeight: 40,
|
||||
margin: 40,
|
||||
pinnedBottom: true
|
||||
})
|
||||
const after = computeWindow({
|
||||
rows: uniform(200, 10),
|
||||
scrollTop: 0,
|
||||
viewportHeight: 40,
|
||||
margin: 40,
|
||||
pinnedBottom: true
|
||||
})
|
||||
expect(before.mounted.has(99)).toBe(true)
|
||||
expect(after.mounted.has(99)).toBe(false)
|
||||
expect(after.mounted.has(199)).toBe(true)
|
||||
})
|
||||
|
||||
test('short content (fits the viewport) clamps to scrollTop 0 → everything mounted', () => {
|
||||
const result = computeWindow({
|
||||
rows: uniform(3, 10),
|
||||
scrollTop: 0,
|
||||
viewportHeight: 40,
|
||||
margin: 40,
|
||||
pinnedBottom: true
|
||||
})
|
||||
expect(result.mounted.size).toBe(3)
|
||||
expect(result.anchor).toBe(0)
|
||||
})
|
||||
|
||||
test('estimates participate in the bottom anchoring (never-measured history)', () => {
|
||||
// 5 estimate-only rows of 100 above 5 exact rows of 10 → content 550;
|
||||
// viewport 40, margin 0 → window [510, 550) → only the exact bottom rows.
|
||||
const rows = [
|
||||
...Array.from({ length: 5 }, (_, i) => row(i, null, { estimate: 100 })),
|
||||
...Array.from({ length: 5 }, (_, i) => row(5 + i, 10))
|
||||
]
|
||||
const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 40, margin: 0, pinnedBottom: true })
|
||||
expect(mountedKeys(result)).toEqual([6, 7, 8, 9])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWindow — never-window and bottom-K rules', () => {
|
||||
test('neverWindow rows stay mounted however far outside the window', () => {
|
||||
const rows = uniform(100, 10)
|
||||
|
|
@ -234,4 +305,71 @@ describe('estimateMessageHeight — line-count estimate for never-mounted rows',
|
|||
const text = Array.from({ length: 10_000 }, (_, i) => `l${i}`).join('\n')
|
||||
expect(estimateMessageHeight({ text }, { top: 0, bottom: 0 }, 0)).toBeLessThanOrEqual(500)
|
||||
})
|
||||
|
||||
test('chips: settled non-system rows count the ⧉ copy line; system rows do not', () => {
|
||||
const spacing0 = { top: 0, bottom: 0 }
|
||||
expect(estimateMessageHeight({ role: 'user', text: 'hi' }, spacing0, 1, true)).toBe(2)
|
||||
expect(estimateMessageHeight({ role: 'system', text: 'note' }, spacing0, 1, true)).toBe(1)
|
||||
expect(estimateMessageHeight({ role: 'user', text: 'hi' }, spacing0, 1, false)).toBe(1)
|
||||
// parts: one chip line per text block, none for tool headers
|
||||
const message: Pick<Message, 'text' | 'parts'> = {
|
||||
text: '',
|
||||
parts: [
|
||||
{ type: 'text', id: 'p1', text: 'one\ntwo' },
|
||||
{ type: 'tool', id: 't1', name: 'terminal', state: 'complete' }
|
||||
]
|
||||
}
|
||||
// (2 text + 1 chip) + 1 tool + 1 gap
|
||||
expect(estimateMessageHeight(message, spacing0, 1, true)).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edgeMeasureBatch — the S2 idle measure picker', () => {
|
||||
test('picks never-measured, unmounted rows nearest the bottom first', () => {
|
||||
const rows = [row(0, null), row(1, null), row(2, 10), row(3, null), row(4, 10)]
|
||||
expect(edgeMeasureBatch(rows, new Set<number>([4]), 10)).toEqual([3, 1, 0])
|
||||
})
|
||||
|
||||
test('respects the batch size (the march is incremental)', () => {
|
||||
const rows = Array.from({ length: 20 }, (_, i) => row(i, null))
|
||||
expect(edgeMeasureBatch(rows, new Set<number>(), 3)).toEqual([19, 18, 17])
|
||||
})
|
||||
|
||||
test('skips mounted, measured, and never-window rows', () => {
|
||||
const rows = [row(0, null), row(1, null, { neverWindow: true }), row(2, null), row(3, 7)]
|
||||
expect(edgeMeasureBatch(rows, new Set<number>([2]), 10)).toEqual([0])
|
||||
})
|
||||
|
||||
test('fully measured transcript → nothing to do', () => {
|
||||
expect(edgeMeasureBatch(uniform(5, 10), new Set<number>(), 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('measureIdleDelayMs — the idle-pulse knob', () => {
|
||||
test('unset/garbage → the 1s default; integers (incl. 0) parse', () => {
|
||||
expect(measureIdleDelayMs(undefined)).toBe(DEFAULT_MEASURE_IDLE_MS)
|
||||
expect(measureIdleDelayMs('soon')).toBe(DEFAULT_MEASURE_IDLE_MS)
|
||||
expect(measureIdleDelayMs('-5')).toBe(DEFAULT_MEASURE_IDLE_MS)
|
||||
expect(measureIdleDelayMs('0')).toBe(0)
|
||||
expect(measureIdleDelayMs(' 250 ')).toBe(250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('windowRowStats — the DEV mounted-row counters', () => {
|
||||
test('tracks current and peak; reset re-bases the peak on the live count', () => {
|
||||
resetWindowRowStats()
|
||||
const base = windowRowStats().mounted
|
||||
noteRowMounted()
|
||||
noteRowMounted()
|
||||
noteRowMounted()
|
||||
expect(windowRowStats().mounted).toBe(base + 3)
|
||||
expect(windowRowStats().peakMounted).toBe(base + 3)
|
||||
noteRowUnmounted()
|
||||
expect(windowRowStats().mounted).toBe(base + 2)
|
||||
expect(windowRowStats().peakMounted).toBe(base + 3) // peak is sticky
|
||||
resetWindowRowStats()
|
||||
expect(windowRowStats().peakMounted).toBe(base + 2) // re-based, live rows kept
|
||||
noteRowUnmounted()
|
||||
noteRowUnmounted()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* 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) ──────
|
||||
* ── Windowing (S1+S2 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 +
|
||||
|
|
@ -24,27 +24,77 @@
|
|||
* 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).
|
||||
* Drivers (S2 — append-time adjudication, not just scroll):
|
||||
* - a renderer frame callback (`setFrameCallback` — scroll always triggers a
|
||||
* render, so every scroll movement is observed; no extra timer) compares
|
||||
* `scrollTop` AND `scrollHeight` to the last computation anchor with
|
||||
* ≥ ¼-viewport hysteresis (logic/window.ts),
|
||||
* - a `createComputed` on `messages.length` re-adjudicates SYNCHRONOUSLY on
|
||||
* every append/splice — while pinned at the bottom the window is anchored
|
||||
* to the cumulative content BOTTOM (`pinnedBottom`), so burst-appended rows
|
||||
* are windowed out the moment they scroll past the margin instead of
|
||||
* ballooning the mounted set until the next frame.
|
||||
* Both publish 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 only when created within the bottom
|
||||
* BOTTOM_ALWAYS_MOUNTED of the transcript or streaming (live rows paint
|
||||
* instantly with zero added latency); a row created deep in a bulk snapshot
|
||||
* (resume `commitSnapshot`) starts as an ESTIMATE spacer — a resumed 2k
|
||||
* session mounts only the bottom window. While a mouse selection is live the
|
||||
* window FREEZES (no swaps — a swap would destroy highlighted renderables out
|
||||
* from under the native selection walk).
|
||||
*
|
||||
* Spacer corrections (S2, the zero-jank rule): when a remount/measure lands a
|
||||
* height different from what the spacer occupied, the wrapper's onSizeChange
|
||||
* fires DURING the layout traversal, before any cell is painted. If the view
|
||||
* is pinned to the bottom the scrollbox's own sticky re-pin (which runs in the
|
||||
* content's onSizeChange, i.e. BEFORE the row wrappers') has already
|
||||
* re-anchored — nothing to do. Otherwise, for a row fully ABOVE the viewport
|
||||
* (`correctionIsLegal`), scrollTop is compensated by the delta in the same
|
||||
* frame, so visible content never moves. Corrections intersecting the
|
||||
* viewport are forbidden and simply not applied (the swap itself is the
|
||||
* accepted "fixed by remount" path).
|
||||
*
|
||||
* Lazy exact-measure (S2, design §4 — the documented SIMPLE choice):
|
||||
* @opentui/core cannot lay out without parenting into the live tree, so there
|
||||
* is no true offscreen measurement. When idle (no appends, no scroll movement,
|
||||
* no running turn, no selection for HERMES_TUI_WINDOW_IDLE_MS ≈ 1s), a pulse
|
||||
* mounts a small batch (MEASURE_BATCH_ROWS) of never-measured rows nearest the
|
||||
* bottom window edge (`edgeMeasureBatch` — they're the next to be seen),
|
||||
* records exact heights, and the next recompute swaps them back to now-exact
|
||||
* spacers; corrections obey the jank rule above. Rows far from the window keep
|
||||
* their estimates until the march reaches them. Scrolling itself measures the
|
||||
* margin band as it always did.
|
||||
*
|
||||
* DEV stats: current/peak simultaneously-mounted rows (logic/window.ts
|
||||
* `windowRowStats`) — exposed on `globalThis.__hermesTuiWindowStats` when
|
||||
* HERMES_TUI_WINDOW_STATS is set, asserted bounded by the headless tests.
|
||||
*
|
||||
* Known S2 limits (documented, deferred): /compact·/details toggles and width
|
||||
* resizes leave out-of-window spacer heights stale until remount or the idle
|
||||
* march (resize invalidation is S3, design §5).
|
||||
*/
|
||||
import type { BoxRenderable, ScrollBoxRenderable } from '@opentui/core'
|
||||
import { useRenderer } from '@opentui/solid'
|
||||
import { createMemo, createSelector, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
|
||||
import { createComputed, createMemo, createSelector, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
|
||||
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
import type { Message, SessionStore } from '../logic/store.ts'
|
||||
import { computeWindow, estimateMessageHeight, hysteresisFor, shouldRecompute } from '../logic/window.ts'
|
||||
import {
|
||||
computeWindow,
|
||||
correctionIsLegal,
|
||||
edgeMeasureBatch,
|
||||
estimateMessageHeight,
|
||||
hysteresisFor,
|
||||
measureIdleDelayMs,
|
||||
noteRowMounted,
|
||||
noteRowUnmounted,
|
||||
shouldRecompute,
|
||||
windowRowStats
|
||||
} from '../logic/window.ts'
|
||||
import { DisplayProvider } from './display.tsx'
|
||||
import { HomeHint } from './homeHint.tsx'
|
||||
import { MessageLine, turnSpacing } from './messageLine.tsx'
|
||||
|
|
@ -60,6 +110,11 @@ import { useTheme } from './theme.tsx'
|
|||
*/
|
||||
const BOTTOM_ALWAYS_MOUNTED = 30
|
||||
|
||||
/** Rows mounted per idle measure pulse (design §4 "small batches"). Small
|
||||
* enough that a pulse's churn is invisible work above the viewport; the march
|
||||
* covers a full resume snapshot over a couple of minutes of idleness. */
|
||||
const MEASURE_BATCH_ROWS = 10
|
||||
|
||||
/** The published window state: which keys are mounted, and which keys the
|
||||
* computation has SEEN (unseen keys default to mounted — see isMounted). */
|
||||
interface WinState {
|
||||
|
|
@ -117,45 +172,131 @@ export function Transcript(props: { store: SessionStore }) {
|
|||
// 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>()
|
||||
// key → the height the LAYOUT currently occupies for the row (real or
|
||||
// spacer/estimate — whatever the wrapper last laid out at). The S2 spacer-
|
||||
// correction compares a fresh measurement against this to compute the delta
|
||||
// that must be compensated when the change sits above the viewport.
|
||||
const assumed = new Map<number, number>()
|
||||
// key → the mount default for rows the window has never adjudicated,
|
||||
// decided at row CREATION: streaming rows and rows created within the bottom
|
||||
// BOTTOM_ALWAYS_MOUNTED of the transcript mount (live rows paint instantly);
|
||||
// anything deeper (a bulk resume snapshot) starts as an estimate spacer.
|
||||
const defaults = new Map<number, boolean>()
|
||||
// key → the row's live measuring wrapper (the idle measure pull below reads
|
||||
// post-layout heights for batch rows whose mount changed nothing).
|
||||
const wrappers = new Map<number, BoxRenderable>()
|
||||
// key → cached settled-row height estimate (estimateFor scans the row text —
|
||||
// caching keeps the per-append adjudication O(rows), not O(text)). Tagged
|
||||
// with the /compact flag it was computed under.
|
||||
const estimates = new Map<number, number>()
|
||||
let estimatesCompact = false
|
||||
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
|
||||
/** Non-reactive mounted-ness (for onSizeChange et al.): the window's verdict
|
||||
* when the key has been adjudicated, its creation default otherwise. */
|
||||
const mountedNow = (key: number): boolean => {
|
||||
if (!liveWin || !liveWin.known.has(key)) return defaults.get(key) ?? true
|
||||
return liveWin.mounted.has(key)
|
||||
}
|
||||
// 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)
|
||||
if (!s || !s.known.has(key)) return defaults.get(key) ?? true
|
||||
return s.mounted.has(key)
|
||||
})
|
||||
const estimateFor = (message: Message): number => {
|
||||
const estimateFor = (message: Message, key: number): number => {
|
||||
const compact = props.store.state.compact
|
||||
return estimateMessageHeight(message, turnSpacing(message.role, compact), compact ? 0 : 1)
|
||||
if (compact !== estimatesCompact) {
|
||||
estimates.clear()
|
||||
estimatesCompact = compact
|
||||
}
|
||||
const streaming = message.streaming ?? false
|
||||
if (!streaming) {
|
||||
const cached = estimates.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
}
|
||||
const estimate = estimateMessageHeight(
|
||||
message,
|
||||
turnSpacing(message.role, compact),
|
||||
compact ? 0 : 1,
|
||||
!compact && !streaming
|
||||
)
|
||||
if (!streaming) estimates.set(key, estimate)
|
||||
return estimate
|
||||
}
|
||||
// DEV stats exposure for the bench (HERMES_TUI_WINDOW_STATS): the live
|
||||
// current/peak mounted-row counters from logic/window.ts.
|
||||
if (envFlag(process.env.HERMES_TUI_WINDOW_STATS, false)) {
|
||||
;(globalThis as unknown as Record<string, unknown>)['__hermesTuiWindowStats'] = windowRowStats()
|
||||
}
|
||||
|
||||
// ── window driver: per-frame scrollTop check (no scroll signal exists; the
|
||||
// frame callback fires on every rendered frame, and scrolling always renders).
|
||||
// ── window drivers: per-frame scrollTop/scrollHeight check (no scroll signal
|
||||
// exists; the frame callback fires on every rendered frame, and scrolling
|
||||
// always renders) + a synchronous re-adjudication on every append (S2).
|
||||
let anchor: number | null = null
|
||||
let lastCount = -1
|
||||
const tick = (): void => {
|
||||
let lastScrollHeight = -1
|
||||
let lastScrollTop = -1
|
||||
let lastActivityAt = Date.now()
|
||||
let lastPulseAt = 0
|
||||
let measureBatch: ReadonlySet<number> = new Set<number>()
|
||||
const idleMs = measureIdleDelayMs(process.env.HERMES_TUI_WINDOW_IDLE_MS)
|
||||
const tick = (force = false): 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
|
||||
if (renderer.getSelection()?.isActive) {
|
||||
lastActivityAt = Date.now()
|
||||
return
|
||||
}
|
||||
const viewportHeight = sb.viewport.height
|
||||
if (viewportHeight <= 0) return
|
||||
const messages = props.store.state.messages
|
||||
// Idle measure pull: a batch row whose mount landed at EXACTLY the spacer
|
||||
// height fires no onSizeChange — read its post-layout height directly so
|
||||
// the march advances past it. (Batch publishes happen only in frame-
|
||||
// callback ticks, so by ANY later tick the batch's layout has run.)
|
||||
for (const key of measureBatch) {
|
||||
if (heights.has(key) || !mountedNow(key)) continue
|
||||
const wrapper = wrappers.get(key)
|
||||
const h = wrapper?.height ?? 0
|
||||
if (h > 0) {
|
||||
heights.set(key, h)
|
||||
assumed.set(key, h)
|
||||
}
|
||||
}
|
||||
const scrollTop = sb.scrollTop
|
||||
const countChanged = messages.length !== lastCount
|
||||
if (!countChanged && !shouldRecompute(scrollTop, anchor, hysteresisFor(viewportHeight))) return
|
||||
const scrollHeight = sb.scrollHeight
|
||||
const running = props.store.state.info.running ?? false
|
||||
const countChanged = messages.length !== lastCount
|
||||
const hysteresis = hysteresisFor(viewportHeight)
|
||||
// Content growth without a count change (streaming deltas) moves
|
||||
// scrollHeight — treat it like scroll movement against the same hysteresis.
|
||||
const heightMoved = lastScrollHeight !== -1 && Math.abs(scrollHeight - lastScrollHeight) >= hysteresis
|
||||
const scrolled = shouldRecompute(scrollTop, anchor, hysteresis)
|
||||
const now = Date.now()
|
||||
if (countChanged || scrollTop !== lastScrollTop || running) lastActivityAt = now
|
||||
lastScrollTop = scrollTop
|
||||
// Idle measure pulse (design §4): only in frame-callback ticks (append
|
||||
// ticks are activity by definition), only when truly idle, only while
|
||||
// some row is still unmeasured (heights covers every live measured key).
|
||||
const pulseDue =
|
||||
!force &&
|
||||
!running &&
|
||||
heights.size < messages.length &&
|
||||
now - lastActivityAt >= idleMs &&
|
||||
now - lastPulseAt >= idleMs
|
||||
if (!force && !countChanged && !heightMoved && !scrolled && !pulseDue) return
|
||||
const rows = messages.map((message, i) => {
|
||||
const key = keyOf(message)
|
||||
const height = heights.get(key) ?? null
|
||||
return {
|
||||
key,
|
||||
height: heights.get(key) ?? null,
|
||||
estimate: estimateFor(message),
|
||||
height,
|
||||
estimate: height === null ? estimateFor(message, key) : undefined,
|
||||
// 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
|
||||
|
|
@ -165,19 +306,44 @@ export function Transcript(props: { store: SessionStore }) {
|
|||
neverWindow: (message.streaming ?? false) || (running && i === messages.length - 1)
|
||||
}
|
||||
})
|
||||
// Pinned to the bottom (sticky region): anchor the window to the content
|
||||
// BOTTOM so rows appended since the last layout are adjudicated against
|
||||
// where the pin will land, not a stale scrollTop (S2 append-time rule).
|
||||
const atBottom = scrollTop >= scrollHeight - viewportHeight
|
||||
const result = computeWindow({
|
||||
rows,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
margin: viewportHeight, // 1 viewport each side (design §Mechanism 1)
|
||||
bottomK: BOTTOM_ALWAYS_MOUNTED
|
||||
bottomK: BOTTOM_ALWAYS_MOUNTED,
|
||||
pinnedBottom: atBottom
|
||||
})
|
||||
anchor = result.anchor
|
||||
lastCount = messages.length
|
||||
lastScrollHeight = scrollHeight
|
||||
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 }
|
||||
// The store cap splices old rows out — drop their per-key records too.
|
||||
if (countChanged) {
|
||||
for (const map of [heights, assumed, defaults, estimates]) {
|
||||
for (const key of map.keys()) if (!known.has(key)) map.delete(key)
|
||||
}
|
||||
}
|
||||
// Idle measure batch: mount the next never-measured rows nearest the
|
||||
// window edge. The previous batch stays mounted until the NEXT pulse
|
||||
// replaces it (not merely until a height lands): async row content — the
|
||||
// native markdown tokenizes over a few frames — needs more than one layout
|
||||
// pass before its recorded height is trustworthy. Once everything is
|
||||
// measured the leftover batch drops back to (exact) spacers.
|
||||
let batch = new Set<number>()
|
||||
if (pulseDue) {
|
||||
batch = new Set(edgeMeasureBatch(rows, result.mounted, MEASURE_BATCH_ROWS))
|
||||
lastPulseAt = now
|
||||
} else if (heights.size < messages.length) {
|
||||
for (const key of measureBatch) if (known.has(key)) batch.add(key)
|
||||
}
|
||||
measureBatch = batch
|
||||
const mounted = batch.size > 0 ? new Set([...result.mounted, ...batch]) : result.mounted
|
||||
liveWin = { mounted, known }
|
||||
setWinState(liveWin)
|
||||
}
|
||||
onMount(() => {
|
||||
|
|
@ -189,28 +355,88 @@ export function Transcript(props: { store: SessionStore }) {
|
|||
renderer.setFrameCallback(frame)
|
||||
onCleanup(() => renderer.removeFrameCallback(frame))
|
||||
})
|
||||
// Append-time adjudication (S2): re-window synchronously when the transcript
|
||||
// grows/shrinks, so a burst of appends can't balloon the mounted set between
|
||||
// frames. `on(..., { defer })` keeps the tick untracked — only the length
|
||||
// re-runs it (content deltas are the frame driver's scrollHeight check).
|
||||
if (windowing) {
|
||||
createComputed(
|
||||
on(
|
||||
() => props.store.state.messages.length,
|
||||
() => tick(true),
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
// Creation default for the not-yet-adjudicated row (see `defaults`):
|
||||
// appended live rows land in the bottom region → mounted instantly; a row
|
||||
// created deep inside a bulk snapshot starts as an estimate spacer.
|
||||
// (Component bodies are untracked — these reads are a one-time snapshot.)
|
||||
defaults.set(
|
||||
key,
|
||||
(rowProps.message.streaming ?? false) ||
|
||||
rowProps.index() >= props.store.state.messages.length - BOTTOM_ALWAYS_MOUNTED
|
||||
)
|
||||
let wrapper: BoxRenderable | undefined
|
||||
onCleanup(() => wrappers.delete(key))
|
||||
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)
|
||||
if (h <= 0) return
|
||||
// Record the exact height only while the REAL row is mounted — a
|
||||
// spacer's (estimate's) height must never overwrite the measurement.
|
||||
if (mountedNow(key)) heights.set(key, h)
|
||||
const prev = assumed.get(key)
|
||||
assumed.set(key, h)
|
||||
if (prev === undefined || prev === h) return
|
||||
// ── S2 spacer correction (the zero-jank rule) ─────────────────────
|
||||
// The row's laid-out height changed (estimate spacer → measured row, or
|
||||
// a re-measure). onSizeChange fires inside the layout traversal, before
|
||||
// paint. The scrollbox content's own onSizeChange (parent — runs FIRST)
|
||||
// already re-pinned the bottom when sticky applies, so at-bottom needs
|
||||
// no compensation here. Otherwise compensate scrollTop for changes
|
||||
// fully ABOVE the viewport — same frame, zero visible movement. The
|
||||
// legality check uses the row's PREVIOUS extent (what the user sees).
|
||||
const sb = scroll()
|
||||
if (!sb) return
|
||||
const viewportHeight = sb.viewport.height
|
||||
if (viewportHeight <= 0) return
|
||||
const scrollTop = sb.scrollTop
|
||||
const atBottom = scrollTop >= sb.scrollHeight - viewportHeight
|
||||
if (atBottom) return // the sticky pin compensated already
|
||||
const rowTop = wrapper.y - sb.content.y // content-space coordinates
|
||||
const rowBottom = rowTop + prev
|
||||
if (correctionIsLegal(rowTop, rowBottom, scrollTop, viewportHeight, atBottom) && rowBottom <= scrollTop) {
|
||||
sb.scrollTop = scrollTop + (h - prev)
|
||||
}
|
||||
}
|
||||
/** The real row, instrumented: the DEV mounted-row counters the headless
|
||||
* tests assert against (and the bench can read — see windowRowStats). */
|
||||
const RealRow = () => {
|
||||
noteRowMounted()
|
||||
onCleanup(noteRowUnmounted)
|
||||
return <MessageLine message={rowProps.message} latest={rowProps.index() === latestAssistant()} />
|
||||
}
|
||||
return (
|
||||
<box ref={el => (wrapper = el)} style={{ flexDirection: 'column', flexShrink: 0 }} onSizeChange={record}>
|
||||
<box
|
||||
ref={el => {
|
||||
wrapper = el
|
||||
wrappers.set(key, el)
|
||||
}}
|
||||
style={{ flexDirection: 'column', flexShrink: 0 }}
|
||||
onSizeChange={record}
|
||||
>
|
||||
<Show
|
||||
when={isMounted(key)}
|
||||
fallback={<box style={{ height: heights.get(key) ?? estimateFor(rowProps.message), flexShrink: 0 }} />}
|
||||
fallback={<box style={{ height: heights.get(key) ?? estimateFor(rowProps.message, key), flexShrink: 0 }} />}
|
||||
>
|
||||
<MessageLine message={rowProps.message} latest={rowProps.index() === latestAssistant()} />
|
||||
<RealRow />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue