mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): transcript windowing S1 — exact-height spacers behind HERMES_TUI_WINDOWING
Core machinery of docs/plans/opentui-transcript-windowing.md (#27): rows outside [scrollTop − viewport, scrollTop + 2·viewport) swap to an exact-height empty <box> (1 yoga node, no text buffers / native handles), so the mounted set stays ~3 viewports regardless of transcript length. Flag: HERMES_TUI_WINDOWING — unset → ON; 0/false/no/off → OFF (envFlag semantics, the bench A/B + one-env escape hatch). OFF renders the exact legacy tree (no wrapper boxes). Pieces: - logic/window.ts (pure, table-tested): computeWindow (viewport ± 1-viewport margin intersection over cumulative exact heights; null heights fall back to a per-row line-count estimate), hysteresisFor/shouldRecompute (≥ ¼ viewport between recomputes), correctionIsLegal (the jank rule: corrections only fully above the viewport with same-frame scrollTop compensation, or fully below it), estimateMessageHeight (line-count estimate; wrong values are fixed by remount only — S1 never corrects a spacer in place). - view/transcript.tsx: per-row measuring wrapper records exact heights via onSizeChange (only while the real row is mounted); window driver is a renderer frame callback (setFrameCallback — scroll always renders, so no extra timer) publishing the mounted set through one signal + createSelector so only flipped rows re-render. Stable row keys via WeakMap<Message, n> (messages have no id; store proxies are reference-stable). Solid <Show> unmount destroys the row's renderables (@opentui/solid _removeNode → destroyRecursively). Never-window rules: - streaming rows (remount would restart native markdown streaming), - the last row while a turn is running (deltas land there), - the bottom 30 rows (fixed K — sticky-bottom region; rows under viewport+margin are mounted by the window calc anyway), - rows the window has never adjudicated default to MOUNTED (new live rows paint instantly), - the whole window FREEZES while a mouse selection is active (renderer.getSelection()?.isActive — a swap would destroy highlighted renderables under the native selection walk). Tests: 30 pure window.test.ts cases + 2 headless integration cases (transcriptWindow.test.tsx) pinning the zero-jank invariant (scrollHeight identical ON vs OFF), the renderable shedding, and remount-on-scroll-back.
This commit is contained in:
parent
8afb7bc570
commit
2d7616121b
4 changed files with 718 additions and 5 deletions
166
ui-opentui/src/logic/window.ts
Normal file
166
ui-opentui/src/logic/window.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* window — pure transcript-windowing math (slice S1 of docs/plans/
|
||||
* opentui-transcript-windowing.md, issue #27). The view (view/transcript.tsx)
|
||||
* replaces out-of-window rows with EXACT-HEIGHT empty boxes (1 yoga node, no
|
||||
* text buffers / native handles), so the mounted set stays ~3 viewports of
|
||||
* rows regardless of transcript length. This module is the testable core:
|
||||
*
|
||||
* - `computeWindow` — which row keys must be mounted for a given scrollTop:
|
||||
* rows intersecting [scrollTop − margin, scrollTop + viewport + margin)
|
||||
* over CUMULATIVE row heights (exact recorded heights; a line-count
|
||||
* estimate stands in for never-measured rows), plus the never-window rows
|
||||
* (streaming/live) and the bottom K rows (sticky-bottom region).
|
||||
* - `shouldRecompute` — the hysteresis gate (≥ ¼ viewport via
|
||||
* `hysteresisFor`): a computed window only changes once scrollTop has
|
||||
* moved ≥ hysteresis from the anchor it was computed at, so swaps don't
|
||||
* thrash at window edges.
|
||||
* - `correctionIsLegal` — the jank rule for spacer-height corrections:
|
||||
* a correction may only touch rows fully ABOVE the viewport (the caller
|
||||
* compensates scrollTop in the same frame — automatic when bottom-anchored
|
||||
* via the sticky pin) or fully BELOW it (invisible by definition). Anything
|
||||
* intersecting the viewport would visibly move content: forbidden.
|
||||
* - `estimateMessageHeight` — the cheap line-count estimate for rows that
|
||||
* have never been measured (resume history above the viewport). S1 never
|
||||
* corrects a wrong estimate in place — it is fixed by remount (scrolling
|
||||
* near) or by the S2 lazy measure pass, both governed by the jank rule.
|
||||
*/
|
||||
import type { Message, Part } from './store.ts'
|
||||
|
||||
/** One transcript row as the window calc sees it. */
|
||||
export interface WindowRow<K> {
|
||||
readonly key: K
|
||||
/** Exact recorded height (the row wrapper's last onSizeChange measurement,
|
||||
* margins included) — or null when the row has never been measured. */
|
||||
readonly height: number | null
|
||||
/** Line-count estimate used while `height` is null (see estimateMessageHeight). */
|
||||
readonly estimate?: number
|
||||
/** Always mounted regardless of the window (streaming/live rows — a remount
|
||||
* would restart native markdown streaming). */
|
||||
readonly neverWindow: boolean
|
||||
}
|
||||
|
||||
export interface WindowParams<K> {
|
||||
readonly rows: readonly WindowRow<K>[]
|
||||
readonly scrollTop: number
|
||||
readonly viewportHeight: number
|
||||
/** Mounted band kept above/below the viewport (design: 1 viewport each side). */
|
||||
readonly margin: number
|
||||
/** Stand-in height for null-height rows without their own estimate. */
|
||||
readonly fallbackHeight?: number
|
||||
/** The bottom K rows are always mounted (sticky-bottom region). */
|
||||
readonly bottomK?: number
|
||||
}
|
||||
|
||||
export interface WindowResult<K> {
|
||||
/** Row keys that must be mounted; everything else renders as a spacer. */
|
||||
readonly mounted: ReadonlySet<K>
|
||||
/** The scrollTop this window was computed at — the next hysteresis anchor. */
|
||||
readonly anchor: number
|
||||
}
|
||||
|
||||
/** Default stand-in for a null-height row with no estimate (≈ a short row). */
|
||||
export const DEFAULT_FALLBACK_HEIGHT = 2
|
||||
|
||||
/** Ceiling on a single row's line-count estimate — a pathological wall of text
|
||||
* must not make the never-mounted region look kilometers tall. */
|
||||
const ESTIMATE_MAX_LINES = 500
|
||||
|
||||
/** Hysteresis for the window recompute: ≥ ¼ viewport (design rule), never 0. */
|
||||
export function hysteresisFor(viewportHeight: number): number {
|
||||
return Math.max(1, Math.ceil(viewportHeight / 4))
|
||||
}
|
||||
|
||||
/** Whether scrollTop has moved far enough from the last computation anchor to
|
||||
* justify a new window (no anchor yet → always). */
|
||||
export function shouldRecompute(scrollTop: number, anchor: number | null, hysteresis: number): boolean {
|
||||
if (anchor === null) return true
|
||||
return Math.abs(scrollTop - anchor) >= hysteresis
|
||||
}
|
||||
|
||||
/** Compute the set of row keys that must be mounted for this scroll position. */
|
||||
export function computeWindow<K>(params: WindowParams<K>): WindowResult<K> {
|
||||
const fallback = params.fallbackHeight ?? DEFAULT_FALLBACK_HEIGHT
|
||||
const bottomK = params.bottomK ?? 0
|
||||
const windowStart = params.scrollTop - params.margin
|
||||
const windowEnd = params.scrollTop + params.viewportHeight + params.margin
|
||||
const total = params.rows.length
|
||||
const mounted = new Set<K>()
|
||||
let top = 0
|
||||
let index = 0
|
||||
for (const r of params.rows) {
|
||||
const height = r.height ?? r.estimate ?? fallback
|
||||
const bottom = top + height
|
||||
// half-open intersection: a row merely touching a window edge stays out.
|
||||
const intersects = bottom > windowStart && top < windowEnd
|
||||
if (intersects || r.neverWindow || index >= total - bottomK) mounted.add(r.key)
|
||||
top = bottom
|
||||
index++
|
||||
}
|
||||
return { mounted, anchor: params.scrollTop }
|
||||
}
|
||||
|
||||
/**
|
||||
* The jank rule: may a spacer-height correction for the row spanning
|
||||
* [rowTop, rowBottom) be applied at this scroll position without visibly
|
||||
* moving content?
|
||||
*
|
||||
* - Fully BELOW the viewport → legal (invisible by definition).
|
||||
* - Fully ABOVE the viewport → legal, PROVIDED the caller compensates
|
||||
* scrollTop by the height delta in the same frame. When `atBottom`
|
||||
* (sticky-bottom pinned) the pin performs that compensation automatically
|
||||
* (bottom-anchored ⇒ zero visual movement); legality is the same either
|
||||
* way — the flag documents which side owes the compensation.
|
||||
* - Intersecting the viewport → forbidden; defer until the row scrolls out
|
||||
* or is remounted for view.
|
||||
*/
|
||||
export function correctionIsLegal(
|
||||
rowTop: number,
|
||||
rowBottom: number,
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
_atBottom: boolean
|
||||
): boolean {
|
||||
if (rowTop >= scrollTop + viewportHeight) return true // fully below the viewport
|
||||
if (rowBottom <= scrollTop) return true // fully above — compensate scrollTop in the same frame
|
||||
return false
|
||||
}
|
||||
|
||||
/** Rendered line count of a text block (1-based; empty text still occupies a row). */
|
||||
function lineCount(text: string): number {
|
||||
if (!text) return 1
|
||||
let lines = 1
|
||||
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) lines++
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Estimated rendered lines of one part: text → its line count (view strips
|
||||
* leading/trailing blanks — mirror that); tool/reasoning → 1 collapsed
|
||||
* header line (the default render for settled, never-mounted history). */
|
||||
function partLines(part: Part): number {
|
||||
if (part.type === 'text') return lineCount(part.text.replace(/^\n+|\n+$/g, ''))
|
||||
return 1 // collapsed tool/reasoning header line
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap line-count height estimate for a row that has never been measured
|
||||
* (S1: resume history above the viewport). Deliberately ignores soft wrapping
|
||||
* — it is a placeholder until the row is actually mounted/measured, and a
|
||||
* wrong value may only be corrected per `correctionIsLegal` (or left until
|
||||
* remount). `spacing` is the row's turnSpacing margins; `gap` the inter-part
|
||||
* blank line (0 in /compact).
|
||||
*/
|
||||
export function estimateMessageHeight(
|
||||
message: Pick<Message, 'text' | 'parts'>,
|
||||
spacing: { readonly top: number; readonly bottom: number },
|
||||
gap: number
|
||||
): number {
|
||||
const parts = message.parts
|
||||
let content: number
|
||||
if (parts && parts.length > 0) {
|
||||
content = gap * (parts.length - 1)
|
||||
for (const part of parts) content += partLines(part)
|
||||
} else {
|
||||
content = lineCount(message.text)
|
||||
}
|
||||
return Math.min(ESTIMATE_MAX_LINES, Math.max(1, content)) + spacing.top + spacing.bottom
|
||||
}
|
||||
126
ui-opentui/src/test/transcriptWindow.test.tsx
Normal file
126
ui-opentui/src/test/transcriptWindow.test.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Transcript windowing S1 — headless integration (view/transcript.tsx +
|
||||
* logic/window.ts behind HERMES_TUI_WINDOWING). Proves, against the REAL
|
||||
* renderer tree:
|
||||
* - out-of-window rows are actually UNMOUNTED (far fewer live renderables
|
||||
* than the unwindowed tree — the spacer is 1 box, the row was ~3+ texts),
|
||||
* - spacers are EXACT-height (total scrollHeight identical ON vs OFF — the
|
||||
* zero-jank invariant),
|
||||
* - scrolling far away REMOUNTS spaced-out rows (content paints again),
|
||||
* - the flag OFF renders the legacy tree (no wrappers, everything mounted).
|
||||
*/
|
||||
import { ScrollBoxRenderable, type Renderable } from '@opentui/core'
|
||||
import { useRenderer } from '@opentui/solid'
|
||||
import { afterEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { createSessionStore } from '../logic/store.ts'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { Transcript } from '../view/transcript.tsx'
|
||||
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
||||
|
||||
type Store = ReturnType<typeof createSessionStore>
|
||||
|
||||
const ENV_KEY = 'HERMES_TUI_WINDOWING'
|
||||
const envBefore = process.env[ENV_KEY]
|
||||
afterEach(() => {
|
||||
if (envBefore === undefined) delete process.env[ENV_KEY]
|
||||
else process.env[ENV_KEY] = envBefore
|
||||
})
|
||||
|
||||
/** Seed `n` settled one-line system rows (flat text — no async markdown). */
|
||||
function seedRows(n: number): Store {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
for (let i = 0; i < n; i++) store.pushSystem(`row-${i} marker`)
|
||||
return store
|
||||
}
|
||||
|
||||
function walk(node: Renderable, visit: (n: Renderable) => void): void {
|
||||
visit(node)
|
||||
for (const child of node.getChildren()) walk(child, visit)
|
||||
}
|
||||
|
||||
interface Mounted {
|
||||
probe: RenderProbe
|
||||
count: () => number
|
||||
scrollbox: () => ScrollBoxRenderable
|
||||
}
|
||||
|
||||
async function mountTranscript(store: Store, windowing: '1' | '0'): Promise<Mounted> {
|
||||
process.env[ENV_KEY] = windowing
|
||||
let root: Renderable | undefined
|
||||
function Grab() {
|
||||
root = useRenderer().root
|
||||
return null
|
||||
}
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<Grab />
|
||||
<Transcript store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 50, height: 12 }
|
||||
)
|
||||
// several passes: layout (heights recorded) → frame tick (window computed)
|
||||
// → swap render — the driver is the renderer frame callback.
|
||||
for (let i = 0; i < 6; i++) await probe.settle()
|
||||
const count = () => {
|
||||
let n = 0
|
||||
if (root) walk(root, () => n++)
|
||||
return n
|
||||
}
|
||||
const scrollbox = () => {
|
||||
let sb: ScrollBoxRenderable | undefined
|
||||
if (root) {
|
||||
walk(root, node => {
|
||||
if (node instanceof ScrollBoxRenderable) sb ??= node
|
||||
})
|
||||
}
|
||||
if (!sb) throw new Error('no scrollbox in the mounted tree')
|
||||
return sb
|
||||
}
|
||||
return { probe, count, scrollbox }
|
||||
}
|
||||
|
||||
const ROWS = 120
|
||||
|
||||
describe('transcript windowing (HERMES_TUI_WINDOWING) — S1 machinery', () => {
|
||||
test('out-of-window rows unmount into exact-height spacers; OFF keeps the full tree', async () => {
|
||||
const on = await mountTranscript(seedRows(ROWS), '1')
|
||||
const off = await mountTranscript(seedRows(ROWS), '0')
|
||||
try {
|
||||
// sticky-bottom: both variants sit pinned at the bottom showing the tail.
|
||||
expect(on.probe.frame()).toContain(`row-${ROWS - 1} marker`)
|
||||
expect(off.probe.frame()).toContain(`row-${ROWS - 1} marker`)
|
||||
|
||||
// ZERO-JANK INVARIANT: spacers are exact-height — the windowed content
|
||||
// is precisely as tall as the fully-mounted content.
|
||||
expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight)
|
||||
expect(on.scrollbox().scrollHeight).toBeGreaterThan(100) // sanity: way past the viewport
|
||||
|
||||
// The window actually sheds renderables: ~viewport±margin + bottom-30
|
||||
// stay mounted out of 120 rows; the rest are 1-box spacers. The legacy
|
||||
// tree keeps every row's text renderables alive.
|
||||
expect(on.count()).toBeLessThan(off.count() * 0.6)
|
||||
} finally {
|
||||
on.probe.destroy()
|
||||
off.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('scrolling far from the bottom remounts spaced-out rows (and the frame paints them)', async () => {
|
||||
const on = await mountTranscript(seedRows(ROWS), '1')
|
||||
try {
|
||||
const sb = on.scrollbox()
|
||||
// row-0 is far outside the bottom window: not painted while pinned.
|
||||
expect(on.probe.frame()).not.toContain('row-0 marker')
|
||||
sb.scrollTo(0)
|
||||
for (let i = 0; i < 6; i++) await on.probe.settle()
|
||||
expect(sb.scrollTop).toBe(0)
|
||||
expect(on.probe.frame()).toContain('row-0 marker')
|
||||
} finally {
|
||||
on.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
237
ui-opentui/src/test/window.test.ts
Normal file
237
ui-opentui/src/test/window.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* window.ts — pure transcript-windowing math (design: docs/plans/
|
||||
* opentui-transcript-windowing.md, slice S1). Table-tests the window calc
|
||||
* (viewport ± margin intersection over cumulative exact heights), the
|
||||
* hysteresis recompute gate, the never-window / bottom-K rules, the
|
||||
* null-height estimate fallback, and the correction-legality jank rule.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { Message } from '../logic/store.ts'
|
||||
import {
|
||||
computeWindow,
|
||||
correctionIsLegal,
|
||||
estimateMessageHeight,
|
||||
hysteresisFor,
|
||||
shouldRecompute,
|
||||
type WindowRow
|
||||
} from '../logic/window.ts'
|
||||
|
||||
function row(
|
||||
key: number,
|
||||
height: number | null,
|
||||
opts?: { neverWindow?: boolean; estimate?: number }
|
||||
): WindowRow<number> {
|
||||
const base = { key, height, neverWindow: opts?.neverWindow ?? false }
|
||||
return opts?.estimate === undefined ? base : { ...base, estimate: opts.estimate }
|
||||
}
|
||||
|
||||
/** n rows of uniform height h, keyed 0..n-1 (row i spans [i*h, (i+1)*h)). */
|
||||
function uniform(n: number, h: number): WindowRow<number>[] {
|
||||
return Array.from({ length: n }, (_, i) => row(i, h))
|
||||
}
|
||||
|
||||
function mountedKeys(result: { mounted: ReadonlySet<number> }): number[] {
|
||||
return [...result.mounted].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
describe('hysteresisFor', () => {
|
||||
test('≥ ¼ viewport, rounded up', () => {
|
||||
expect(hysteresisFor(40)).toBe(10)
|
||||
expect(hysteresisFor(5)).toBe(2)
|
||||
expect(hysteresisFor(4)).toBe(1)
|
||||
})
|
||||
|
||||
test('never below 1 row (degenerate viewports)', () => {
|
||||
expect(hysteresisFor(0)).toBe(1)
|
||||
expect(hysteresisFor(2)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRecompute', () => {
|
||||
test('no prior anchor → always recompute', () => {
|
||||
expect(shouldRecompute(0, null, 10)).toBe(true)
|
||||
expect(shouldRecompute(500, null, 10)).toBe(true)
|
||||
})
|
||||
|
||||
test('movement below hysteresis → keep the current window', () => {
|
||||
expect(shouldRecompute(108, 100, 10)).toBe(false)
|
||||
expect(shouldRecompute(92, 100, 10)).toBe(false)
|
||||
expect(shouldRecompute(100, 100, 10)).toBe(false)
|
||||
})
|
||||
|
||||
test('movement at/above hysteresis (either direction) → recompute', () => {
|
||||
expect(shouldRecompute(110, 100, 10)).toBe(true)
|
||||
expect(shouldRecompute(90, 100, 10)).toBe(true)
|
||||
expect(shouldRecompute(250, 100, 10)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWindow — viewport ± margin intersection', () => {
|
||||
// 100 rows × 10 → content height 1000. Viewport 40, margin 40 (1 viewport),
|
||||
// scrollTop 480 → window [440, 560). Row i spans [10i, 10i+10).
|
||||
const base = { viewportHeight: 40, margin: 40, scrollTop: 480 }
|
||||
|
||||
test('mounts exactly the rows intersecting [scrollTop − margin, scrollTop + viewport + margin)', () => {
|
||||
const result = computeWindow({ rows: uniform(100, 10), ...base })
|
||||
expect(mountedKeys(result)).toEqual([44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55])
|
||||
})
|
||||
|
||||
test('rows merely TOUCHING the window edge are not mounted', () => {
|
||||
const result = computeWindow({ rows: uniform(100, 10), ...base })
|
||||
// row 43 spans [430, 440) — its bottom touches windowStart 440: out.
|
||||
expect(result.mounted.has(43)).toBe(false)
|
||||
// row 56 spans [560, 570) — its top touches windowEnd 560: out.
|
||||
expect(result.mounted.has(56)).toBe(false)
|
||||
})
|
||||
|
||||
test('anchor echoes the scrollTop the window was computed at', () => {
|
||||
expect(computeWindow({ rows: uniform(100, 10), ...base }).anchor).toBe(480)
|
||||
expect(computeWindow({ rows: [], scrollTop: 7, viewportHeight: 40, margin: 40 }).anchor).toBe(7)
|
||||
})
|
||||
|
||||
test('scrolled to the top: window clamps naturally (no negative-row weirdness)', () => {
|
||||
const result = computeWindow({ rows: uniform(100, 10), scrollTop: 0, viewportHeight: 40, margin: 40 })
|
||||
// window [-40, 80) → rows 0..7
|
||||
expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
})
|
||||
|
||||
test('empty transcript → empty window', () => {
|
||||
const result = computeWindow({ rows: [], scrollTop: 0, viewportHeight: 40, margin: 40 })
|
||||
expect(result.mounted.size).toBe(0)
|
||||
})
|
||||
|
||||
test('everything fits in the window → everything mounted', () => {
|
||||
const result = computeWindow({ rows: uniform(5, 2), scrollTop: 0, viewportHeight: 40, margin: 40 })
|
||||
expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4])
|
||||
})
|
||||
|
||||
test('works with non-numeric keys (generic)', () => {
|
||||
const rows: WindowRow<string>[] = [
|
||||
{ key: 'a', height: 50, neverWindow: false },
|
||||
{ key: 'b', height: 50, neverWindow: false },
|
||||
{ key: 'c', height: 50, neverWindow: false }
|
||||
]
|
||||
const result = computeWindow({ rows, scrollTop: 60, viewportHeight: 30, margin: 0 })
|
||||
// window [60, 90) → only 'b' ([50, 100)) intersects
|
||||
expect([...result.mounted]).toEqual(['b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWindow — never-window and bottom-K rules', () => {
|
||||
test('neverWindow rows stay mounted however far outside the window', () => {
|
||||
const rows = uniform(100, 10)
|
||||
rows[90] = row(90, 10, { neverWindow: true })
|
||||
const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 40, margin: 40 })
|
||||
expect(result.mounted.has(90)).toBe(true)
|
||||
expect(result.mounted.has(89)).toBe(false)
|
||||
})
|
||||
|
||||
test('the bottom K rows are always mounted (sticky-bottom region)', () => {
|
||||
const result = computeWindow({ rows: uniform(100, 10), scrollTop: 0, viewportHeight: 40, margin: 40, bottomK: 5 })
|
||||
expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 95, 96, 97, 98, 99])
|
||||
})
|
||||
|
||||
test('bottomK larger than the transcript mounts everything', () => {
|
||||
const result = computeWindow({ rows: uniform(10, 10), scrollTop: 0, viewportHeight: 4, margin: 0, bottomK: 50 })
|
||||
expect(result.mounted.size).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWindow — null heights use the estimate', () => {
|
||||
test('a per-row estimate stands in for a never-measured height (and shifts later offsets)', () => {
|
||||
// row 0 estimated at 100 → row 1 starts at 100; window [100, 110) hits only row 1.
|
||||
const rows = [row(0, null, { estimate: 100 }), row(1, 10)]
|
||||
const result = computeWindow({ rows, scrollTop: 100, viewportHeight: 10, margin: 0 })
|
||||
expect(mountedKeys(result)).toEqual([1])
|
||||
})
|
||||
|
||||
test('a recorded height wins over the estimate', () => {
|
||||
const rows = [row(0, 10, { estimate: 100 }), row(1, 10)]
|
||||
const result = computeWindow({ rows, scrollTop: 100, viewportHeight: 10, margin: 0 })
|
||||
// row 0 is REALLY 10 tall → row 1 spans [10, 20): nothing in [100, 110).
|
||||
expect(result.mounted.size).toBe(0)
|
||||
})
|
||||
|
||||
test('null height with no estimate falls back to fallbackHeight', () => {
|
||||
const rows = [row(0, null), row(1, 10)]
|
||||
const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 10, margin: 0, fallbackHeight: 100 })
|
||||
// row 0 assumed [0, 100) → mounted; row 1 [100, 110) → out of [0, 10).
|
||||
expect(mountedKeys(result)).toEqual([0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('correctionIsLegal — the jank rule', () => {
|
||||
// viewport shows [100, 140)
|
||||
const scrollTop = 100
|
||||
const viewportHeight = 40
|
||||
|
||||
test.each([true, false])('fully ABOVE the viewport is legal (compensation applies) — atBottom=%s', atBottom => {
|
||||
expect(correctionIsLegal(20, 60, scrollTop, viewportHeight, atBottom)).toBe(true)
|
||||
// boundary: row bottom touching the viewport top is still fully above
|
||||
expect(correctionIsLegal(50, 100, scrollTop, viewportHeight, atBottom)).toBe(true)
|
||||
})
|
||||
|
||||
test.each([true, false])('fully BELOW the viewport is legal (invisible) — atBottom=%s', atBottom => {
|
||||
expect(correctionIsLegal(150, 170, scrollTop, viewportHeight, atBottom)).toBe(true)
|
||||
// boundary: row top touching the viewport bottom is still fully below
|
||||
expect(correctionIsLegal(140, 160, scrollTop, viewportHeight, atBottom)).toBe(true)
|
||||
})
|
||||
|
||||
test.each([true, false])('any intersection with the viewport is FORBIDDEN — atBottom=%s', atBottom => {
|
||||
expect(correctionIsLegal(90, 110, scrollTop, viewportHeight, atBottom)).toBe(false) // clips the top edge
|
||||
expect(correctionIsLegal(130, 150, scrollTop, viewportHeight, atBottom)).toBe(false) // clips the bottom edge
|
||||
expect(correctionIsLegal(110, 120, scrollTop, viewportHeight, atBottom)).toBe(false) // inside
|
||||
expect(correctionIsLegal(90, 150, scrollTop, viewportHeight, atBottom)).toBe(false) // spans the whole viewport
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateMessageHeight — line-count estimate for never-mounted rows', () => {
|
||||
const spacing = { top: 2, bottom: 1 }
|
||||
|
||||
test('flat row: newline count + turn spacing', () => {
|
||||
expect(estimateMessageHeight({ text: 'hello' }, spacing, 1)).toBe(1 + 3)
|
||||
expect(estimateMessageHeight({ text: 'a\nb\nc' }, spacing, 1)).toBe(3 + 3)
|
||||
})
|
||||
|
||||
test('empty text still occupies at least one row', () => {
|
||||
expect(estimateMessageHeight({ text: '' }, { top: 0, bottom: 0 }, 0)).toBe(1)
|
||||
})
|
||||
|
||||
test('parts row: text lines + 1 per collapsed tool/reasoning + inter-part gaps', () => {
|
||||
const message: Pick<Message, 'text' | 'parts'> = {
|
||||
text: '',
|
||||
parts: [
|
||||
{ type: 'text', id: 'p1', text: 'line1\nline2' },
|
||||
{ type: 'tool', id: 't1', name: 'terminal', state: 'complete' },
|
||||
{ type: 'reasoning', id: 'p2', text: 'thought\nover\nlines' }
|
||||
]
|
||||
}
|
||||
// 2 (text) + 1 (tool header) + 1 (collapsed reasoning) + 2 gaps + 3 spacing
|
||||
expect(estimateMessageHeight(message, spacing, 1)).toBe(2 + 1 + 1 + 2 + 3)
|
||||
})
|
||||
|
||||
test('text parts strip leading/trailing blank lines (the view does the same)', () => {
|
||||
const message: Pick<Message, 'text' | 'parts'> = {
|
||||
text: '',
|
||||
parts: [{ type: 'text', id: 'p1', text: '\n\nhello\n' }]
|
||||
}
|
||||
expect(estimateMessageHeight(message, { top: 0, bottom: 0 }, 1)).toBe(1)
|
||||
})
|
||||
|
||||
test('compact mode (gap 0, no margins) collapses the chrome', () => {
|
||||
const message: Pick<Message, 'text' | 'parts'> = {
|
||||
text: '',
|
||||
parts: [
|
||||
{ type: 'text', id: 'p1', text: 'one' },
|
||||
{ type: 'tool', id: 't1', name: 'terminal', state: 'complete' }
|
||||
]
|
||||
}
|
||||
expect(estimateMessageHeight(message, { top: 0, bottom: 0 }, 0)).toBe(2)
|
||||
})
|
||||
|
||||
test('a pathological wall of text is clamped', () => {
|
||||
const text = Array.from({ length: 10_000 }, (_, i) => `l${i}`).join('\n')
|
||||
expect(estimateMessageHeight({ text }, { top: 0, bottom: 0 }, 0)).toBeLessThanOrEqual(500)
|
||||
})
|
||||
})
|
||||
|
|
@ -13,20 +13,76 @@
|
|||
*
|
||||
* A `ScrollAnchorProvider` gives collapse/expand toggles (tool/thinking) a handle
|
||||
* to hold the viewport in place so expanding doesn't yank to the bottom (#4).
|
||||
*
|
||||
* ── Windowing (S1 of docs/plans/opentui-transcript-windowing.md, #27) ──────
|
||||
* Behind `HERMES_TUI_WINDOWING` (unset → ON; 0/false/no/off → OFF), each row is
|
||||
* wrapped in a measuring box (`onSizeChange` records its exact laid-out height,
|
||||
* margins included) and rows outside [scrollTop − viewport, scrollTop +
|
||||
* 2·viewport) swap to an EXACT-HEIGHT empty spacer `<box height={recorded}/>`
|
||||
* — 1 yoga node, no text buffers, no native handles — so the mounted set stays
|
||||
* ~3 viewports of rows regardless of transcript length (the 671MB→Ink-parity
|
||||
* memory fix). The Solid `<Show>` unmount destroys the row's renderables
|
||||
* (@opentui/solid `_removeNode` → `destroyRecursively()` once unparented).
|
||||
*
|
||||
* Driver: a renderer frame callback (`setFrameCallback` — scroll always
|
||||
* triggers a render, so every scroll movement is observed; no extra timer)
|
||||
* compares `scrollTop` to the last computation anchor with ≥ ¼-viewport
|
||||
* hysteresis (logic/window.ts) and publishes the mounted-key set through one
|
||||
* signal + `createSelector`, so only rows whose mounted-ness actually flipped
|
||||
* re-render. Never windowed: streaming rows, the last row while a turn runs,
|
||||
* and the bottom BOTTOM_ALWAYS_MOUNTED rows (see its doc). Rows the window has
|
||||
* never adjudicated default to MOUNTED (new live rows must paint instantly).
|
||||
* While a mouse selection is live the window FREEZES (no swaps — a swap would
|
||||
* destroy highlighted renderables out from under the native selection walk).
|
||||
* S1 never corrects a spacer height in place — wrong estimates (possible only
|
||||
* for never-measured resume history above the viewport) are fixed by remount;
|
||||
* `correctionIsLegal` governs anything smarter (S2).
|
||||
*/
|
||||
import type { ScrollBoxRenderable } from '@opentui/core'
|
||||
import { createMemo, createSignal, For, Show } from 'solid-js'
|
||||
import type { BoxRenderable, ScrollBoxRenderable } from '@opentui/core'
|
||||
import { useRenderer } from '@opentui/solid'
|
||||
import { createMemo, createSelector, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
import type { Message, SessionStore } from '../logic/store.ts'
|
||||
import { computeWindow, estimateMessageHeight, hysteresisFor, shouldRecompute } from '../logic/window.ts'
|
||||
import { DisplayProvider } from './display.tsx'
|
||||
import { HomeHint } from './homeHint.tsx'
|
||||
import { MessageLine } from './messageLine.tsx'
|
||||
import { MessageLine, turnSpacing } from './messageLine.tsx'
|
||||
import { ScrollAnchorProvider } from './scrollAnchor.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
/**
|
||||
* The bottom K rows are ALWAYS mounted (the sticky-bottom region the user
|
||||
* lives in; also the zone where swap turbulence would be most visible). 30 is
|
||||
* a fixed, documented pick (the design's alternative — ceil(viewport/avg-row)
|
||||
* — buys little: rows under the viewport+margin are mounted by the window calc
|
||||
* anyway, so K only backstops sticky re-pins and burst appends).
|
||||
*/
|
||||
const BOTTOM_ALWAYS_MOUNTED = 30
|
||||
|
||||
/** The published window state: which keys are mounted, and which keys the
|
||||
* computation has SEEN (unseen keys default to mounted — see isMounted). */
|
||||
interface WinState {
|
||||
readonly mounted: ReadonlySet<number>
|
||||
readonly known: ReadonlySet<number>
|
||||
}
|
||||
|
||||
function sameSet(a: ReadonlySet<number>, b: ReadonlySet<number>): boolean {
|
||||
if (a.size !== b.size) return false
|
||||
for (const k of a) if (!b.has(k)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Signal equality for WinState — identical sets must not re-notify selectors. */
|
||||
function sameWinState(a: WinState | undefined, b: WinState | undefined): boolean {
|
||||
if (!a || !b) return a === b
|
||||
return sameSet(a.mounted, b.mounted) && sameSet(a.known, b.known)
|
||||
}
|
||||
|
||||
export function Transcript(props: { store: SessionStore }) {
|
||||
const [scroll, setScroll] = createSignal<ScrollBoxRenderable | undefined>()
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const dropped = () => props.store.state.dropped
|
||||
const sid = () => props.store.state.sessionId
|
||||
// The NEWEST assistant answer's index — gold is earned (design pass): only
|
||||
|
|
@ -38,6 +94,128 @@ export function Transcript(props: { store: SessionStore }) {
|
|||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
// ── windowing state (S1) ───────────────────────────────────────────────
|
||||
// Read once per transcript: the flag is an A/B + escape hatch, not live config.
|
||||
const windowing = envFlag(process.env.HERMES_TUI_WINDOWING, true)
|
||||
// Stable row keys: messages carry no id and the store relies on reference
|
||||
// identity (<For> keys by item reference; solid-js/store proxies are cached
|
||||
// per underlying object, so the reference is stable across reads/mutations).
|
||||
// A WeakMap-assigned monotonic number gives the window math a primitive key
|
||||
// without restructuring the store or mutating Message objects.
|
||||
const rowKeys = new WeakMap<Message, number>()
|
||||
let rowSeq = 0
|
||||
const keyOf = (message: Message): number => {
|
||||
let key = rowKeys.get(message)
|
||||
if (key === undefined) {
|
||||
key = ++rowSeq
|
||||
rowKeys.set(message, key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
// key → last exact height measured while the REAL row was mounted (the
|
||||
// wrapper's onSizeChange value; includes the row's margins). Non-reactive:
|
||||
// spacers read it once at swap time, the frame driver reads it per compute.
|
||||
const heights = new Map<number, number>()
|
||||
const [winState, setWinState] = createSignal<WinState | undefined>(undefined, { equals: sameWinState })
|
||||
// Non-reactive mirror of the latest winState for event callbacks (onSizeChange
|
||||
// must not subscribe; createSelector reads are for tracked scopes).
|
||||
let liveWin: WinState | undefined
|
||||
// Per-row mounted-ness: only rows whose answer FLIPPED re-run their <Show>.
|
||||
// Keys the window has never adjudicated (no state yet / not in `known`, e.g.
|
||||
// a message appended since the last compute) default to MOUNTED.
|
||||
const isMounted = createSelector(winState, (key: number, s: WinState | undefined) => {
|
||||
return !s || !s.known.has(key) || s.mounted.has(key)
|
||||
})
|
||||
const estimateFor = (message: Message): number => {
|
||||
const compact = props.store.state.compact
|
||||
return estimateMessageHeight(message, turnSpacing(message.role, compact), compact ? 0 : 1)
|
||||
}
|
||||
|
||||
// ── window driver: per-frame scrollTop check (no scroll signal exists; the
|
||||
// frame callback fires on every rendered frame, and scrolling always renders).
|
||||
let anchor: number | null = null
|
||||
let lastCount = -1
|
||||
const tick = (): void => {
|
||||
const sb = scroll()
|
||||
if (!sb) return
|
||||
// Selection freeze: the native selection walks the LIVE tree — swapping a
|
||||
// row out (destroying its renderables) mid-selection would corrupt the
|
||||
// highlight/copy. Frozen ≠ broken: unseen new rows default to mounted.
|
||||
if (renderer.getSelection()?.isActive) return
|
||||
const viewportHeight = sb.viewport.height
|
||||
if (viewportHeight <= 0) return
|
||||
const messages = props.store.state.messages
|
||||
const scrollTop = sb.scrollTop
|
||||
const countChanged = messages.length !== lastCount
|
||||
if (!countChanged && !shouldRecompute(scrollTop, anchor, hysteresisFor(viewportHeight))) return
|
||||
const running = props.store.state.info.running ?? false
|
||||
const rows = messages.map((message, i) => {
|
||||
const key = keyOf(message)
|
||||
return {
|
||||
key,
|
||||
height: heights.get(key) ?? null,
|
||||
estimate: estimateFor(message),
|
||||
// Never window: a streaming row (remount would restart native markdown
|
||||
// streaming) and the last row while a turn runs (deltas land there).
|
||||
// A row with an expanded tool/reasoning body is NOT detectable from
|
||||
// here (the override lives in component-local signals — toolPart.tsx/
|
||||
// reasoningPart.tsx); expanded rows far above the viewport may
|
||||
// re-collapse on remount. Accepted for S1.
|
||||
neverWindow: (message.streaming ?? false) || (running && i === messages.length - 1)
|
||||
}
|
||||
})
|
||||
const result = computeWindow({
|
||||
rows,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
margin: viewportHeight, // 1 viewport each side (design §Mechanism 1)
|
||||
bottomK: BOTTOM_ALWAYS_MOUNTED
|
||||
})
|
||||
anchor = result.anchor
|
||||
lastCount = messages.length
|
||||
const known = new Set(rows.map(r => r.key))
|
||||
// The store cap splices old rows out — drop their recorded heights too.
|
||||
if (countChanged) for (const key of heights.keys()) if (!known.has(key)) heights.delete(key)
|
||||
liveWin = { mounted: result.mounted, known }
|
||||
setWinState(liveWin)
|
||||
}
|
||||
onMount(() => {
|
||||
if (!windowing) return
|
||||
const frame = (_deltaTime: number): Promise<void> => {
|
||||
tick()
|
||||
return Promise.resolve()
|
||||
}
|
||||
renderer.setFrameCallback(frame)
|
||||
onCleanup(() => renderer.removeFrameCallback(frame))
|
||||
})
|
||||
|
||||
/** One windowed row: a measuring wrapper around the real MessageLine or an
|
||||
* exact-height spacer. The wrapper stays mounted either way (1 box), so its
|
||||
* `onSizeChange` keeps the height record fresh while the row is real. */
|
||||
const WindowedRow = (rowProps: { message: Message; index: () => number }) => {
|
||||
const key = keyOf(rowProps.message)
|
||||
let wrapper: BoxRenderable | undefined
|
||||
const record = (): void => {
|
||||
if (!wrapper) return
|
||||
// Only record while the REAL row is mounted — a spacer's (or estimate's)
|
||||
// height must never overwrite the exact measurement.
|
||||
if (liveWin && liveWin.known.has(key) && !liveWin.mounted.has(key)) return
|
||||
const h = wrapper.height
|
||||
if (h > 0) heights.set(key, h)
|
||||
}
|
||||
return (
|
||||
<box ref={el => (wrapper = el)} style={{ flexDirection: 'column', flexShrink: 0 }} onSizeChange={record}>
|
||||
<Show
|
||||
when={isMounted(key)}
|
||||
fallback={<box style={{ height: heights.get(key) ?? estimateFor(rowProps.message), flexShrink: 0 }} />}
|
||||
>
|
||||
<MessageLine message={rowProps.message} latest={rowProps.index() === latestAssistant()} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<box style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
|
||||
|
|
@ -58,7 +236,13 @@ export function Transcript(props: { store: SessionStore }) {
|
|||
</text>
|
||||
</Show>
|
||||
<For each={props.store.state.messages}>
|
||||
{(message, i) => <MessageLine message={message} latest={i() === latestAssistant()} />}
|
||||
{(message, i) =>
|
||||
windowing ? (
|
||||
<WindowedRow message={message} index={i} />
|
||||
) : (
|
||||
<MessageLine message={message} latest={i() === latestAssistant()} />
|
||||
)
|
||||
}
|
||||
</For>
|
||||
</DisplayProvider>
|
||||
</ScrollAnchorProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue