mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-15 04:12:25 +00:00
fix(tui): bound virtual history offset searches
This commit is contained in:
parent
a345f7b6e5
commit
946ef0ea19
2 changed files with 123 additions and 4 deletions
119
ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts
Normal file
119
ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { Box, renderSync, ScrollBox, Text, type ScrollBoxHandle } from '@hermes/ink'
|
||||||
|
import React, { useLayoutEffect, useRef } from 'react'
|
||||||
|
import { PassThrough } from 'stream'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
height: number
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Exposed {
|
||||||
|
scroll: ScrollBoxHandle | null
|
||||||
|
virtualHistory: ReturnType<typeof useVirtualHistory>
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
const makeStreams = () => {
|
||||||
|
const stdout = new PassThrough()
|
||||||
|
const stdin = new PassThrough()
|
||||||
|
const stderr = new PassThrough()
|
||||||
|
|
||||||
|
Object.assign(stdout, { columns: 80, isTTY: false, rows: 20 })
|
||||||
|
Object.assign(stdin, { isTTY: false })
|
||||||
|
Object.assign(stderr, { isTTY: false })
|
||||||
|
stdout.on('data', () => {})
|
||||||
|
|
||||||
|
return { stderr, stdin, stdout }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountedSpan = (items: readonly Item[], virtualHistory: ReturnType<typeof useVirtualHistory>) => {
|
||||||
|
let height = 0
|
||||||
|
|
||||||
|
for (let index = virtualHistory.start; index < virtualHistory.end; index++) {
|
||||||
|
height += items[index]?.height ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bottom: virtualHistory.topSpacer + height, top: virtualHistory.topSpacer }
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType<typeof useVirtualHistory>, scroll: ScrollBoxHandle) => {
|
||||||
|
const span = mountedSpan(items, virtualHistory)
|
||||||
|
const top = scroll.getScrollTop()
|
||||||
|
const bottom = top + scroll.getViewportHeight()
|
||||||
|
|
||||||
|
return top >= span.top && bottom <= span.bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | null>; items: readonly Item[] }) {
|
||||||
|
const scrollRef = useRef<ScrollBoxHandle | null>(null)
|
||||||
|
const virtualHistory = useVirtualHistory(scrollRef, items, 80, {
|
||||||
|
coldStartCount: 16,
|
||||||
|
estimateHeight: index => items[index]?.height ?? 1,
|
||||||
|
maxMounted: 16,
|
||||||
|
overscan: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
expose.current = { scroll: scrollRef.current, virtualHistory }
|
||||||
|
})
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
ScrollBox,
|
||||||
|
{ flexDirection: 'column', height: 10, ref: scrollRef, stickyScroll: true },
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: 'column', width: '100%' },
|
||||||
|
virtualHistory.topSpacer > 0 ? React.createElement(Box, { height: virtualHistory.topSpacer }) : null,
|
||||||
|
...items
|
||||||
|
.slice(virtualHistory.start, virtualHistory.end)
|
||||||
|
.map(item =>
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ height: item.height, key: item.key, ref: virtualHistory.measureRef(item.key) },
|
||||||
|
React.createElement(Text, null, item.key)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useVirtualHistory offset cache reuse', () => {
|
||||||
|
it('ignores stale reused offset-array entries after the item count shrinks', async () => {
|
||||||
|
const beforeShrink = Array.from({ length: 1400 }, (_, index) => ({ height: 1, key: `old${index}` }))
|
||||||
|
const afterShrink = Array.from({ length: 800 }, (_, index) => ({ height: 7, key: `new${index}` }))
|
||||||
|
const expose = { current: null as Exposed | null }
|
||||||
|
const streams = makeStreams()
|
||||||
|
const instance = renderSync(React.createElement(Harness, { expose, items: beforeShrink }), {
|
||||||
|
patchConsole: false,
|
||||||
|
stderr: streams.stderr as NodeJS.WriteStream,
|
||||||
|
stdin: streams.stdin as NodeJS.ReadStream,
|
||||||
|
stdout: streams.stdout as NodeJS.WriteStream
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await delay(20)
|
||||||
|
instance.rerender(React.createElement(Harness, { expose, items: afterShrink }))
|
||||||
|
await delay(20)
|
||||||
|
|
||||||
|
const scroll = expose.current!.scroll!
|
||||||
|
const transcriptHeight = expose.current!.virtualHistory.offsets[afterShrink.length] ?? 0
|
||||||
|
|
||||||
|
expect(transcriptHeight).toBe(5600)
|
||||||
|
expect(scroll.getScrollTop()).toBe(transcriptHeight - scroll.getViewportHeight())
|
||||||
|
|
||||||
|
scroll.scrollBy(-1)
|
||||||
|
await delay(80)
|
||||||
|
|
||||||
|
expect(scroll.getPendingDelta()).toBe(0)
|
||||||
|
expect(viewportIsMounted(afterShrink, expose.current!.virtualHistory, scroll)).toBe(true)
|
||||||
|
} finally {
|
||||||
|
instance.unmount()
|
||||||
|
instance.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -51,9 +51,9 @@ const SLIDE_STEP = 12
|
||||||
|
|
||||||
const NOOP = () => {}
|
const NOOP = () => {}
|
||||||
|
|
||||||
const upperBound = (arr: ArrayLike<number>, target: number) => {
|
const upperBound = (arr: ArrayLike<number>, target: number, length = arr.length) => {
|
||||||
let lo = 0
|
let lo = 0
|
||||||
let hi = arr.length
|
let hi = length
|
||||||
|
|
||||||
while (lo < hi) {
|
while (lo < hi) {
|
||||||
const mid = (lo + hi) >> 1
|
const mid = (lo + hi) >> 1
|
||||||
|
|
@ -282,8 +282,8 @@ export function useVirtualHistory(
|
||||||
|
|
||||||
// Binary search — offsets is monotone. Linear walk was O(n) at n=10k+,
|
// Binary search — offsets is monotone. Linear walk was O(n) at n=10k+,
|
||||||
// ~2ms per render during scroll.
|
// ~2ms per render during scroll.
|
||||||
start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo) - 1))
|
start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo, n + 1) - 1))
|
||||||
end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi)))
|
end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi, n + 1)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue