diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 4cfb435c7c..8e43f60ea6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -463,11 +463,8 @@ export default class Ink { this.resetFramesForAltScreen() this.needsEraseBeforePaint = true - // Post-resize drift healer: 160ms after the last resize, force one full - // reconcile so Yoga/React catch up to the final viewport and any stale - // terminal cells from host-side reflow get repainted away. Ink upstream - // and ConPTY/xterm reports point to this as a general resize/reflow - // desync class, not an xterm.js-only quirk. + // One last repaint after the resize burst settles closes any host-side + // reflow drift the normal diff path can't see. this.resizeSettleTimer = setTimeout(() => { this.resizeSettleTimer = null diff --git a/ui-tui/src/__tests__/viewport.test.ts b/ui-tui/src/__tests__/viewport.test.ts index 0a949e44c5..d8500c8d20 100644 --- a/ui-tui/src/__tests__/viewport.test.ts +++ b/ui-tui/src/__tests__/viewport.test.ts @@ -13,7 +13,7 @@ describe('stickyPromptFromViewport', () => { const offsets = [0, 2, 10, 12, 20] - expect(stickyPromptFromViewport(messages, offsets, 16, 8, false)).toBe('') + expect(stickyPromptFromViewport(messages, offsets, 8, 16, false)).toBe('') }) it('shows the latest user message above the viewport when no user message is visible', () => { @@ -26,6 +26,6 @@ describe('stickyPromptFromViewport', () => { const offsets = [0, 2, 10, 12, 20] - expect(stickyPromptFromViewport(messages, offsets, 20, 16, false)).toBe('current prompt') + expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt') }) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index fdfdd8d54c..75fe73c86d 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -22,7 +22,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' -import { type AppLayoutProgressProps, type GatewayRpc, type TranscriptRow } from './interfaces.js' +import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { $turnState, patchTurnState } from './turnStore.js' @@ -672,16 +672,11 @@ export function useMainApp(gw: GatewayClient) { return top + vp >= total - 3 })() - const liveProgress = useMemo( - () => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), - [turn, showProgressArea] - ) + const liveProgress = useMemo(() => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), [turn, showProgressArea]) const frozenProgressRef = useRef(liveProgress) - // When the live tail is offscreen, freeze its snapshot so scroll work doesn't - // keep rebuilding the streaming/thinking subtree the user can't see. Thaw as - // soon as the viewport comes back near the bottom or the turn finishes. + // Freeze the offscreen live tail so scroll doesn't rebuild unseen streaming UI. if (liveTailVisible || !ui.busy) { frozenProgressRef.current = liveProgress } diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index d7974d5332..8de2a63019 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -249,28 +249,15 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), () => { - const s = scrollRef.current - - if (!s) { - return NaN - } - - const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) - const vp = Math.max(0, s.getViewportHeight()) - const total = Math.max(vp, s.getScrollHeight()) - const atBottom = s.isSticky() || top + vp >= total - 2 + const { atBottom, top } = getStickyViewport(scrollRef.current) return atBottom ? -1 - top : top }, () => NaN ) - const s = scrollRef.current - const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) - const vp = Math.max(0, s?.getViewportHeight() ?? 0) - const total = Math.max(vp, s?.getScrollHeight() ?? vp) - const atBottom = (s?.isSticky() ?? true) || top + vp >= total - 2 - const text = stickyPromptFromViewport(messages, offsets, top + vp, top, atBottom) + const { atBottom, bottom, top } = getStickyViewport(scrollRef.current) + const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom) useEffect(() => onChange(text), [onChange, text]) @@ -395,3 +382,15 @@ interface TranscriptScrollbarProps { scrollRef: RefObject t: Theme } + +function getStickyViewport(s?: ScrollBoxHandle | null) { + const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + + return { + atBottom: (s?.isSticky() ?? true) || top + vp >= total - 2, + bottom: top + vp, + top + } +} diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 3a358eb6f2..48d7427fd1 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -18,8 +18,8 @@ const upperBound = (offsets: ArrayLike, target: number) => { export const stickyPromptFromViewport = ( messages: readonly Msg[], offsets: ArrayLike, - bottom: number, top: number, + bottom: number, sticky: boolean ) => { if (sticky || !messages.length) {