diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 6ef1fc5fbd8..3d5be7b5434 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -21,6 +21,11 @@ export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js' export { useTerminalTitle } from './ink/hooks/use-terminal-title.js' export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' export { default as measureElement } from './ink/measure-element.js' +export { + resetScrollFastPathStats, + scrollFastPathStats, + type ScrollFastPathStats +} from './ink/render-node-to-output.js' export { createRoot, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index 12d689c166f..cb781f3e696 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -67,6 +67,54 @@ export function resetScrollHint(): void { absoluteRectsCur = [] } +// Fast-path diagnostics. Bumped from the ScrollBox fast-path branch +// whenever a scroll hint was captured. Reveals why a fast path was +// declined (heightDelta mismatch, no prevScreen, etc.) so we can chase +// the last mile of PageUp/wheel latency. Zero cost when no reader — +// it's all integer bumps. Exposed as a counter object so external +// probes can snapshot + diff. +export type ScrollFastPathStats = { + captured: number + taken: number + declined: { + noPrevScreen: number + heightDeltaMismatch: number + noHint: number + other: number + } + lastDeclineReason?: string + lastHeightDelta?: number + lastHintDelta?: number + lastScrollHeight?: number + lastPrevHeight?: number +} + +export const scrollFastPathStats: ScrollFastPathStats = { + captured: 0, + taken: 0, + declined: { + noPrevScreen: 0, + heightDeltaMismatch: 0, + noHint: 0, + other: 0 + } +} + +export function resetScrollFastPathStats(): void { + scrollFastPathStats.captured = 0 + scrollFastPathStats.taken = 0 + scrollFastPathStats.declined.noPrevScreen = 0 + scrollFastPathStats.declined.heightDeltaMismatch = 0 + scrollFastPathStats.declined.noHint = 0 + scrollFastPathStats.declined.other = 0 + scrollFastPathStats.lastDeclineReason = undefined + scrollFastPathStats.lastHeightDelta = undefined + scrollFastPathStats.lastHintDelta = undefined + scrollFastPathStats.lastScrollHeight = undefined + scrollFastPathStats.lastPrevHeight = undefined +} + + export function getScrollHint(): ScrollHint | null { return scrollHint } @@ -927,6 +975,27 @@ function renderNodeToOutput( const safeForFastPath = !hint || heightDelta === 0 || (hint.delta > 0 && heightDelta === hint.delta) + // Diagnostics (opt-in via scrollFastPathStats reader). Only + // counts when a hint was captured — cases where nothing scrolled + // (hint === null) are not declines, just idle frames. + if (hint) { + scrollFastPathStats.captured++ + scrollFastPathStats.lastHintDelta = hint.delta + scrollFastPathStats.lastScrollHeight = scrollHeight + scrollFastPathStats.lastPrevHeight = prevHeight + scrollFastPathStats.lastHeightDelta = heightDelta + + if (!safeForFastPath) { + scrollFastPathStats.declined.heightDeltaMismatch++ + scrollFastPathStats.lastDeclineReason = `heightDelta=${heightDelta} hintDelta=${hint.delta}` + } else if (!prevScreen) { + scrollFastPathStats.declined.noPrevScreen++ + scrollFastPathStats.lastDeclineReason = 'noPrevScreen' + } else { + scrollFastPathStats.taken++ + } + } + // scrollHint is set above when hint is captured. If safeForFastPath // is false the full path renders a next.screen that doesn't match // the DECSTBM shift — emitting DECSTBM leaves stale rows (seen as diff --git a/ui-tui/src/lib/perfPane.tsx b/ui-tui/src/lib/perfPane.tsx index feae1f0b3b1..331fb62dc52 100644 --- a/ui-tui/src/lib/perfPane.tsx +++ b/ui-tui/src/lib/perfPane.tsx @@ -44,6 +44,7 @@ import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { FrameEvent } from '@hermes/ink' +import { scrollFastPathStats } from '@hermes/ink' import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react' const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').trim()) @@ -122,8 +123,29 @@ export const logFrameEvent = ENABLED return } + // Snapshot the fast-path counters each frame. Cumulative values — + // consumers diff pairs to get per-frame deltas. Written verbatim + // so we can also see "last*" fields (which decline reason fired, + // and what the height math looked like). + const fastPath = { + captured: scrollFastPathStats.captured, + taken: scrollFastPathStats.taken, + declined: { + heightDeltaMismatch: scrollFastPathStats.declined.heightDeltaMismatch, + noHint: scrollFastPathStats.declined.noHint, + noPrevScreen: scrollFastPathStats.declined.noPrevScreen, + other: scrollFastPathStats.declined.other + }, + lastDeclineReason: scrollFastPathStats.lastDeclineReason, + lastHeightDelta: scrollFastPathStats.lastHeightDelta, + lastHintDelta: scrollFastPathStats.lastHintDelta, + lastPrevHeight: scrollFastPathStats.lastPrevHeight, + lastScrollHeight: scrollFastPathStats.lastScrollHeight + } + writeRow({ durationMs: round2(event.durationMs), + fastPath, flickers: event.flickers.length ? event.flickers : undefined, phases: event.phases ? { diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 762166af202..0ad9a957ef1 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -101,6 +101,24 @@ declare module '@hermes/ink' { export const TextInput: React.ComponentType export const stringWidth: (s: string) => number + export type ScrollFastPathStats = { + captured: number + taken: number + declined: { + noPrevScreen: number + heightDeltaMismatch: number + noHint: number + other: number + } + lastDeclineReason?: string + lastHeightDelta?: number + lastHintDelta?: number + lastScrollHeight?: number + lastPrevHeight?: number + } + export const scrollFastPathStats: ScrollFastPathStats + export function resetScrollFastPathStats(): void + export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance export function useApp(): { readonly exit: (error?: Error) => void }