CPU profile (Apr 2026, real-user scroll on 11k-line session) showed three
hot loops in the per-frame render path:
Output.get() per-frame walk: 24% total
└─ sliceAnsi(line, from, to) per write: 18% total
stringWidth(line) chain (cached + JS): 14% total
All three were re-doing identical work every frame: same string → same
clipped slice → same width.
Fixes:
1. Memoize stringWidth (8k-entry LRU) for non-ASCII strings; ASCII fast-path
skips the cache (inline scan beats Map.get for short ASCII, the >90%
case). String.charCodeAt scan up to 64 chars is cheaper than the regex
fallback.
2. Memoize wrapText (4k-entry LRU keyed by maxWidth|wrapType|text) — wrapAnsi
is pure and the same content reflows identically every frame.
3. Memoize sliceAnsi (4k-entry LRU keyed by start|end|str) for the
end-defined hot path used by Output.get().
4. Skip the slice entirely in Output.get() when the line already fits the
clip box (startsBefore=false && endsAfter=false). Most transcript lines
never exceed their container width, and tokenizing them just to slice
(line, 0, width) was pure overhead. This single fast-path drops
sliceAnsi from 18% → ~0% in the profile.
Also tighten virtualization constants (MAX_MOUNTED 260→120, OVERSCAN 40→20,
SLIDE_STEP 25→12) and cap historical-message render at 800 chars / 16
lines via HISTORY_RENDER_MAX_*; messages inside the FULL_RENDER_TAIL_ITEMS
window still render in full so reading-zone behavior is unchanged.
Validation, real-user CPU profile, page-up scroll on 11k-line session:
Output.get() self-time: 24% → 0.3%
sliceAnsi total: 18% → not in top 25
stringWidth family: 14% → ~3%
idle: 60.7% → 77.3%
Frame timings (synthetic page-up profile harness):
dur p95: ~10ms → 4.87ms
dur p99: 25ms+ → 12.80ms
yoga p99: ~20ms → 1.87ms
The remaining CPU in the profile is Yoga layoutNode + React commit,
which is the irreducible work for this UI tree size.
Six small fixes, all valid review feedback:
- gatewayClient: onTimeout is now a class-field arrow so setTimeout gets a
stable reference — no per-request bind allocation (the whole point of
the original refactor).
- memory: growth rate was lifetime average of rss/uptime, which reports
phantom growth for stable processes. Now computed as delta since a
module-load baseline (STARTED_AT). Sanity-checked: 0.00 MB/hr at
steady-state, non-zero after an allocation.
- hermes_cli: NODE_OPTIONS merge is now token-aware — respects a
user-supplied --max-old-space-size (don't downgrade a deliberate 16GB
setting) and avoids duplicating --expose-gc.
- useVirtualHistory: if items shrink past the frozen range's start
mid-freeze (/clear, compaction), drop the freeze and fall through to
the normal range calc instead of collapsing to an empty mount.
- circularBuffer: throw on non-positive capacity instead of silently
producing NaN indices.
- debug slash help: /heapdump mentions HERMES_HEAPDUMP_DIR override
instead of hardcoding the default path.
Validation: tsc clean, eslint clean, vitest 102/102, growth-rate smoke
test confirms baseline=0 → post-alloc>0.
KISS/DRY sweep — drops ~90 LOC with no behavior change.
- circularBuffer: drop unused pushAll/toArray/size; fold toArray into drain
- gracefulExit: inline Cleanup type + failsafe const; signal→code as a
record instead of nested ternary; drop dead .catch on Promise.allSettled;
drop unused forceExit
- memory: inline heapDumpRoot() + writeSnapshot() (single-use); collapse
the two fd/smaps try/catch blocks behind one `swallow` helper; build
potentialLeaks functionally (array+filter) instead of imperative
push-chain; UNITS at file bottom
- memoryMonitor: inline DEFAULTS; drop unused onSnapshot; collapse
dumpedHigh/dumpedCritical bools to a single Set; single callback
dispatch line instead of duplicated if-chains
- entry.tsx: factor `dumpNotice` formatter (used twice by onHigh +
onCritical)
- useMainApp resize debounce: drop redundant `if (timer)` guards
(clearTimeout(undefined) is a no-op); init as undefined not null
- useVirtualHistory: trim wall-of-text comment to one-line intent; hoist
`const n = items.length`; split comma-declared lets; remove the
`;[start, end] = frozenRange` destructure in favor of direct Math.min
clamps; hoist `hi` init in upperBound for consistency
Validation: tsc clean (both configs), eslint clean on touched files,
vitest 102/102, build produces shebang-preserved dist/entry.js,
performHeapDump smoke-test still writes valid snapshot + diagnostics.
VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering
an unthrottled `terminal.resize` gateway RPC and a full transcript
re-virtualization with stale per-row height cache.
## Changes
### gateway RPC debounce (ui-tui/src/app/useMainApp.ts)
- `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols`
state stays synchronous (needed for Yoga / in-process rendering),
only the round-trip to Python coalesces. Prevents gateway flood
during panel-drag / tmux-pane-resize.
### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts)
- New required `columns` param, plumbed through from useMainApp.
- On column change: scale every cached row height by `oldCols/newCols`
(Math.max 1, Math.round) instead of clearing. Clearing forces a
pessimistic back-walk that mounts ~190 rows at once (viewport + 2x
overscan at 1-row estimate), each a fresh marked.lexer + syntax
highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep
the back-walk tight.
- `freezeRenders=2`: reuse pre-resize mount range for 2 renders so
already-mounted MessageRows keep their warm useMemo results. Without
this the first post-resize render would unmount + remount most rows
(pessimistic coverage) = visible flash + 150 ms+ freeze.
- `skipMeasurement` flag: first post-resize useLayoutEffect would read
PRE-resize Yoga heights (Yoga's stored values are still from the
frame before this render's calculateLayout with new width) and
poison the scaled cache. Skip the measurement loop for that one
render; next render's Yoga is correct.
## Validation
- tsc `--noEmit` clean
- eslint clean on touched files
- `vitest run`: 15 files / 102 tests passing
The renderer-level resize patterns (sync-dim-capture + microtask-
coalesced React commit, atomic BSU/ESU erase-before-paint, mouse-
tracking reassert) already live in hermes-ink's own `handleResize`;
this patch adds the matching app-layer hygiene.
Adds useGitBranch hook (async, cached, 15s TTL) and fmtCwdBranch
helper so the footer shows `~/repo (main)` instead of just `~/repo`.
Degrades silently when git is unavailable or cwd is outside a repo.
Partial fix for #12267 (TUI portion; #12277 covers the Python side).
- useVirtualHistory: track last-seen ScrollBox metrics in a ref inside
the post-layout effect and bump ver when sticky/top/vp change — the
subscribe-based rearm was sufficient for fresh clicks but not for the
"hydrated mid-commit, measured empty, then metrics settle" path where
nothing re-triggered the hook until the next unrelated keystroke
- useSessionLifecycle: resume scrollToBottom from queueMicrotask to
setTimeout(..., 0) so the fresh transcript has a full task turn to
commit + measure before we try to land at the newest content
useVirtualHistory set up its useSyncExternalStore subscription during
the first render, when scrollRef.current was still null (the ScrollBox
ref attaches during commit, after render). Its useCallback for
subscribe had a stable scrollRef identity as its only dep, so it never
re-subscribed once the ref actually attached — the hook stayed stuck
with vp=0, top=0, no scroll subscription. Small sessions fit entirely
in cold-start so you didn't notice; big /resume sessions got sliced to
the last 40 items with a huge topSpacer and the viewport sat on empty
space until some unrelated state change (e.g. a keystroke) re-rendered
and finally read a real vp.
- flip a hasScrollRef flag in useLayoutEffect once the ref attaches and
add it to the subscribe useCallback deps so useSyncExternalStore
rearms with a real subscription
- on resume, scrollToBottom() after history hydrates so the ScrollBox
lands at the newest messages instead of scrollTop=0 (stickyScroll
doesn't auto-engage on the initial empty→full dump)