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:
talmax1124 2026-06-30 18:33:37 -04:00 committed by Teknium
parent ff4c17411c
commit 671b1b058e
3 changed files with 154 additions and 1 deletions

View file

@ -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) {

View 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)
})
})

View 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
}
}
}