mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 09:11:54 +00:00
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:
parent
4395c2b007
commit
85e9a23efb
5 changed files with 152 additions and 4 deletions
|
|
@ -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>
|
||||
|
|
|
|||
48
ui-tui/src/components/fpsOverlay.tsx
Normal file
48
ui-tui/src/components/fpsOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(<App gw={gw} />, { 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<NonNullable<typeof logFrameEvent>>[0])
|
||||
trackFrame?.(event.durationMs)
|
||||
}
|
||||
: undefined
|
||||
|
||||
render(<App gw={gw} />, { exitOnCtrlC: false, onFrame })
|
||||
|
|
|
|||
69
ui-tui/src/lib/fpsStore.ts
Normal file
69
ui-tui/src/lib/fpsStore.ts
Normal file
|
|
@ -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<FpsState>({
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue