diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 332aca961e..7fe8e156b9 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -6,7 +6,7 @@ import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' -import { INLINE_MODE } from '../config/env.js' +import { INLINE_MODE, SHOW_FPS } from '../config/env.js' import { PLACEHOLDER } from '../content/placeholders.js' import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' import { PerfPane } from '../lib/perfPane.js' @@ -15,6 +15,7 @@ import { AgentsOverlay } from './agentsOverlay.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { FloatingOverlays, PromptZone } from './appOverlays.js' import { Banner, Panel, SessionPanel } from './branding.js' +import { FpsOverlay } from './fpsOverlay.js' import { MessageLine } from './messageLine.js' import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' @@ -290,6 +291,15 @@ export const AppLayout = memo(function AppLayout({ + + {/* FPS counter overlay: pinned to the bottom row, right + aligned, gated on HERMES_TUI_FPS. Returns null + skips + this subtree when disabled (zero cost). */} + {SHOW_FPS && ( + + + + )} )} diff --git a/ui-tui/src/components/fpsOverlay.tsx b/ui-tui/src/components/fpsOverlay.tsx new file mode 100644 index 0000000000..d3d5aca777 --- /dev/null +++ b/ui-tui/src/components/fpsOverlay.tsx @@ -0,0 +1,48 @@ +// FPS counter overlay — renders in the bottom-right corner when +// HERMES_TUI_FPS=1. Zero-cost when disabled (returns null at the +// top of the component; React skips the whole subtree). +// +// Subscribes to $fpsState via nanostores. The store is only updated +// when the env flag is on (trackFrame is undefined otherwise), so we +// also gate the subscription on SHOW_FPS to avoid a useless listener. + +import { Text } from '@hermes/ink' +import { useStore } from '@nanostores/react' + +import { SHOW_FPS } from '../config/env.js' +import { $fpsState } from '../lib/fpsStore.js' + +const fpsColor = (fps: number) => { + if (fps >= 50) { + return 'green' + } + + if (fps >= 30) { + return 'yellow' + } + + return 'red' +} + +export function FpsOverlay() { + if (!SHOW_FPS) { + return null + } + + return +} + +function FpsOverlayInner() { + const { fps, lastDurationMs, totalFrames } = useStore($fpsState) + + // Zero-pad to stable width so the corner doesn't jitter as digits + // come and go. Format: " 62fps 0.3ms #12345" + const fpsStr = fps.toFixed(1).padStart(5) + const durStr = lastDurationMs.toFixed(1).padStart(5) + + return ( + + {fpsStr}fps · {durStr}ms · #{totalFrames} + + ) +} diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 96de9a99fe..d20e09617b 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -12,3 +12,8 @@ export const NO_CONFIRM_DESTRUCTIVE = /^(?:1|true|yes|on)$/i.test((process.env.H // here just disables AlternateScreen so we can measure whether native // scrolling beats our virtualization on the same pipeline. export const INLINE_MODE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_INLINE ?? '').trim()) +// Show a small FPS counter overlay in the bottom-right corner. Fed by +// ink's onFrame callback (so it's the REAL render rate, not a synthetic +// timer). Useful during scroll-perf tuning to watch behavior in real +// time instead of running a separate profile harness. +export const SHOW_FPS = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_FPS ?? '').trim()) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 92ae4a71c0..da827eab26 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -41,10 +41,26 @@ if (process.env.HERMES_HEAPDUMP_ON_START === '1') { process.on('beforeExit', () => stopMemoryMonitor()) -const [{ render }, { App }, { logFrameEvent }] = await Promise.all([ +const [{ render }, { App }, { logFrameEvent }, { trackFrame }] = await Promise.all([ import('@hermes/ink'), import('./app.js'), - import('./lib/perfPane.js') + import('./lib/perfPane.js'), + import('./lib/fpsStore.js') ]) -render(, { exitOnCtrlC: false, onFrame: logFrameEvent }) +// Compose onFrame from the two opt-in consumers (HERMES_DEV_PERF and +// HERMES_TUI_FPS). Each is undefined when its env flag is off; we only +// attach onFrame at all when at least one is on, so ink skips the +// handler entirely in the default disabled case. +type InkFrameEvent = { durationMs: number } +type OnFrame = (event: InkFrameEvent) => void + +const onFrame: OnFrame | undefined = + logFrameEvent || trackFrame + ? (event: InkFrameEvent) => { + logFrameEvent?.(event as Parameters>[0]) + trackFrame?.(event.durationMs) + } + : undefined + +render(, { exitOnCtrlC: false, onFrame }) diff --git a/ui-tui/src/lib/fpsStore.ts b/ui-tui/src/lib/fpsStore.ts new file mode 100644 index 0000000000..530e6229c5 --- /dev/null +++ b/ui-tui/src/lib/fpsStore.ts @@ -0,0 +1,69 @@ +// Tiny FPS tracker fed by ink's onFrame callback. +// +// Keeps a ring buffer of the last N frame timestamps and derives fps +// from the rolling window. Updates a nanostore so a corner-overlay +// component can subscribe without pulling it through props. +// +// FPS here means "Ink render rate" — each entry is an ink frame, which +// includes both React commits and drain-only frames (Ink re-rendering +// with an updated scrollTop without a React commit). That's the right +// notion for user-perceived motion: it's how often the screen buffer +// actually changes, not how often React reconciles. +// +// Zero-cost when HERMES_TUI_FPS is unset: trackFrame is undefined so +// the onFrame callback short-circuits at the optional chain. + +import { atom } from 'nanostores' + +import { SHOW_FPS } from '../config/env.js' + +const WINDOW_SIZE = 30 // last 30 frames + +export type FpsState = { + /** Frames per second averaged over the last WINDOW_SIZE frames. */ + fps: number + /** Total frames counted since start (wraps at JS-safe int so you can + * diff pairs in a debug overlay without worrying about precision). */ + totalFrames: number + /** Last frame's durationMs (ink render phase total). */ + lastDurationMs: number +} + +export const $fpsState = atom({ + fps: 0, + lastDurationMs: 0, + totalFrames: 0 +}) + +const timestamps: number[] = [] +let totalFrames = 0 + +export const trackFrame = SHOW_FPS + ? (durationMs: number) => { + const now = performance.now() + + timestamps.push(now) + + if (timestamps.length > WINDOW_SIZE) { + timestamps.shift() + } + + totalFrames++ + + // FPS = frames-in-window / seconds-in-window. Needs at least 2 + // timestamps to compute a gap. + if (timestamps.length >= 2) { + const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000 + + if (elapsed > 0) { + const fps = (timestamps.length - 1) / elapsed + + $fpsState.set({ + fps: Math.round(fps * 10) / 10, + lastDurationMs: Math.round(durationMs * 100) / 100, + totalFrames + }) + } + } + } + : undefined