diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 19da0daf210..6443493c7b6 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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) { diff --git a/ui-tui/src/lib/resizeCoalescer.test.ts b/ui-tui/src/lib/resizeCoalescer.test.ts new file mode 100644 index 00000000000..b7db0c14748 --- /dev/null +++ b/ui-tui/src/lib/resizeCoalescer.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/lib/resizeCoalescer.ts b/ui-tui/src/lib/resizeCoalescer.ts new file mode 100644 index 00000000000..fd63127d4b8 --- /dev/null +++ b/ui-tui/src/lib/resizeCoalescer.ts @@ -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 | 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 + } + } +}