mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
test(tui): extract resize coalescer into a unit-tested helper
Pull the inline leading+trailing resize throttle out of useMainApp into createResizeCoalescer (src/lib/resizeCoalescer.ts) and cover it directly with fake-timer tests: leading-edge immediacy, burst collapse to one trailing reflow, fresh leading edge after the window, cancel() dropping a pending reflow, and sustained-drag staying ~one reflow per interval. Also seed lastReflow at -Infinity instead of 0 so the leading edge fires on the first event independent of the wall clock (the inline version only worked because Date.now() is large at runtime).
This commit is contained in:
parent
ff4c17411c
commit
671b1b058e
3 changed files with 154 additions and 1 deletions
|
|
@ -23,6 +23,7 @@ import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
|||
import { composerPromptWidth } from '../lib/inputMetrics.js'
|
||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
|
||||
import { createResizeCoalescer } from '../lib/resizeCoalescer.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
||||
|
|
@ -145,7 +146,15 @@ export function useMainApp(gw: GatewayClient) {
|
|||
return
|
||||
}
|
||||
|
||||
const sync = () => setCols(stdout.columns ?? 80)
|
||||
// A drag-resize emits a burst of 'resize' events; syncing `cols` on every
|
||||
// one remounts the visible transcript rows each tick (they're keyed on
|
||||
// cols so yoga re-measures), turning a smooth drag into a flickering
|
||||
// remount storm. Coalesce the burst with a leading+trailing throttle: the
|
||||
// first event reflows immediately (the drag stays responsive), the rest
|
||||
// collapse to at most one reflow per RESIZE_COALESCE_MS, and the trailing
|
||||
// edge always applies the final width so the settled layout is exact.
|
||||
const coalescer = createResizeCoalescer(() => setCols(stdout.columns ?? 80), RESIZE_COALESCE_MS)
|
||||
const sync = () => coalescer.schedule()
|
||||
|
||||
stdout.on('resize', sync)
|
||||
|
||||
|
|
@ -154,6 +163,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
}
|
||||
|
||||
return () => {
|
||||
coalescer.cancel()
|
||||
stdout.off('resize', sync)
|
||||
|
||||
if (stdout.isTTY) {
|
||||
|
|
|
|||
87
ui-tui/src/lib/resizeCoalescer.test.ts
Normal file
87
ui-tui/src/lib/resizeCoalescer.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createResizeCoalescer } from './resizeCoalescer.js'
|
||||
|
||||
describe('createResizeCoalescer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reflows immediately on the first event (leading edge)', () => {
|
||||
const reflow = vi.fn()
|
||||
const coalescer = createResizeCoalescer(reflow, 32)
|
||||
|
||||
coalescer.schedule()
|
||||
|
||||
expect(reflow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('collapses a rapid burst into one leading + one trailing reflow', () => {
|
||||
const reflow = vi.fn()
|
||||
const coalescer = createResizeCoalescer(reflow, 32)
|
||||
|
||||
// A drag: five events inside one 32ms window.
|
||||
coalescer.schedule()
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
vi.advanceTimersByTime(5)
|
||||
coalescer.schedule()
|
||||
}
|
||||
|
||||
// Only the leading edge has fired mid-burst.
|
||||
expect(reflow).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The trailing edge lands the final width once the window settles.
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(reflow).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reflows immediately again once the interval has elapsed', () => {
|
||||
const reflow = vi.fn()
|
||||
const coalescer = createResizeCoalescer(reflow, 32)
|
||||
|
||||
coalescer.schedule()
|
||||
expect(reflow).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Gap longer than the window — the next event is a fresh leading edge.
|
||||
vi.advanceTimersByTime(40)
|
||||
coalescer.schedule()
|
||||
|
||||
expect(reflow).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancel() drops a pending trailing reflow', () => {
|
||||
const reflow = vi.fn()
|
||||
const coalescer = createResizeCoalescer(reflow, 32)
|
||||
|
||||
coalescer.schedule() // leading
|
||||
vi.advanceTimersByTime(5)
|
||||
coalescer.schedule() // schedules a trailing reflow
|
||||
coalescer.cancel()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(reflow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('continuous dragging reflows about once per interval, not per event', () => {
|
||||
const reflow = vi.fn()
|
||||
const coalescer = createResizeCoalescer(reflow, 30)
|
||||
|
||||
// 30 events over 300ms (one every 10ms) — a sustained drag.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
coalescer.schedule()
|
||||
vi.advanceTimersByTime(10)
|
||||
}
|
||||
|
||||
// Flush the final trailing reflow.
|
||||
vi.advanceTimersByTime(30)
|
||||
|
||||
// ~300ms / 30ms ≈ 10 reflows, not 30. Bound it loosely to stay robust.
|
||||
expect(reflow.mock.calls.length).toBeLessThanOrEqual(12)
|
||||
expect(reflow.mock.calls.length).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
56
ui-tui/src/lib/resizeCoalescer.ts
Normal file
56
ui-tui/src/lib/resizeCoalescer.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export interface ResizeCoalescer {
|
||||
/** Call on each terminal 'resize' event. */
|
||||
schedule: () => void
|
||||
/** Drop any pending trailing reflow (effect cleanup). */
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading + trailing throttle for terminal-resize bursts.
|
||||
*
|
||||
* A drag-resize emits a burst of 'resize' events; reflowing on every one
|
||||
* remounts the visible transcript rows each tick (they're keyed on cols so
|
||||
* yoga re-measures off live geometry), turning a smooth drag into a
|
||||
* flickering remount storm.
|
||||
*
|
||||
* `schedule()` reflows immediately on the first event (the drag stays
|
||||
* responsive), then collapses subsequent events to at most one `reflow()`
|
||||
* per `intervalMs`, and always fires a trailing `reflow()` so the final
|
||||
* width lands exactly. `cancel()` clears a pending trailing reflow.
|
||||
*
|
||||
* Uses `Date.now()` + `setTimeout` directly so it is deterministically
|
||||
* testable under fake timers.
|
||||
*/
|
||||
export function createResizeCoalescer(reflow: () => void, intervalMs: number): ResizeCoalescer {
|
||||
// -Infinity (not 0) so the first schedule() always satisfies the
|
||||
// elapsed >= interval leading-edge check regardless of the wall clock.
|
||||
let lastReflow = Number.NEGATIVE_INFINITY
|
||||
let trailing: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const run = () => {
|
||||
lastReflow = Date.now()
|
||||
reflow()
|
||||
}
|
||||
|
||||
return {
|
||||
schedule() {
|
||||
const elapsed = Date.now() - lastReflow
|
||||
|
||||
clearTimeout(trailing)
|
||||
trailing = undefined
|
||||
|
||||
if (elapsed >= intervalMs) {
|
||||
run()
|
||||
} else {
|
||||
trailing = setTimeout(() => {
|
||||
trailing = undefined
|
||||
run()
|
||||
}, intervalMs - elapsed)
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
clearTimeout(trailing)
|
||||
trailing = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue