feat(tui): HERMES_TUI_FPS=1 shows live fps counter

Adds a corner-overlay FPS readout gated on HERMES_TUI_FPS, fed by
ink's onFrame callback (so it's the REAL render rate, not a timer).
Displays fps, last-frame duration, and total frame count, colored by
threshold (green ≥50, yellow ≥30, red below).

Implementation:
  * lib/fpsStore.ts — nanostore atom updated from a trackFrame()
    sink.  Ring buffer of last 30 frame timestamps; fps = 29/elapsed.
    trackFrame is undefined when SHOW_FPS is off so ink's onFrame
    short-circuits at the optional chain.
  * components/fpsOverlay.tsx — tiny <Text> subscriber; returns null
    when SHOW_FPS is off (React skips the subtree entirely).
  * entry.tsx — composes onFrame from logFrameEvent (dev-perf) and
    trackFrame (fps) so both flags can coexist.  When both are off,
    onFrame is undefined and ink never attaches the handler.
  * appLayout.tsx — mounts the overlay as a flex-shrink=0 right-
    aligned Box below the composer, conditional on SHOW_FPS.

Usage:
  HERMES_TUI_FPS=1 hermes --tui
  # bottom right: "  62.3fps ·   0.8ms · #1234" (green/yellow/red)

Intended as a user-facing diagnostic during the scroll-perf tuning
pass — watch the counter drop while holding PageUp to see where
frames go silent, without having to run scripts/profile-tui.py in a
side terminal.

126 files post-compile with React Compiler; 352 tests still pass.
This commit is contained in:
Brooklyn Nicholson 2026-04-26 17:20:47 -05:00
parent 4395c2b007
commit 85e9a23efb
5 changed files with 152 additions and 4 deletions

View file

@ -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({
<PerfPane id="composer">
<ComposerPane actions={actions} composer={composer} status={status} />
</PerfPane>
{/* 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 && (
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
<FpsOverlay />
</Box>
)}
</>
)}
</Box>

View file

@ -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 <FpsOverlayInner />
}
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 (
<Text color={fpsColor(fps)}>
{fpsStr}fps · {durStr}ms · #{totalFrames}
</Text>
)
}