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