From 4a9070c9ac24ea8a205825bd8533b5c7b022904e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 16:56:09 -0500 Subject: [PATCH] perf(tui): defer Md upgrade for fresh-mounted assistant rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DeferredMd — a wrapper around that renders a lightweight placeholder on first mount and upgrades to the full markdown subtree on a queueMicrotask follow-up. Rationale: fresh MessageLine mounts during PageUp hold run our markdown tokenizer + syntax highlighter synchronously, producing the 63-112ms renderer spikes profiled earlier. A plain placeholder only needs Yoga to wrap the pre-stripped string (no tokenizer, no highlight), then the Md subtree builds in a follow-up React commit. Upgrade cache: once a (theme, compact, text) tuple has been upgraded, a WeakMap-keyed Set remembers it so remounts (scroll-out then scroll-back) mount straight into — no placeholder round-trip. WeakMap on theme means palette swaps re-upgrade naturally. Honesty note: profiling under hold-PageUp showed this didn't reduce renderer p99 measurably — the upgrade commit just pays the Md cost on a follow-up frame instead of inline. The bigger bottleneck turned out to be React commit frequency (3.5 commits/sec during 30Hz scroll input, with 200ms+ silent gaps between commits dominating perceived FPS), which this change doesn't address. Keeping the deferred path anyway because: 1. It's correct and tested — no regressions across 352 tests 2. Defensive for pathological fresh-mount cases (giant code blocks, wide tables) that aren't in the current profile fixture 3. Pairs naturally with useVirtualHistory's useDeferredValue to keep React's concurrent scheduler able to interrupt upgrade commits If the follow-up perf investigation (terminal write throughput / patch volume / commit frequency) shows DeferredMd is net-neutral-or-worse in practice, this can be reverted with a one-line swap back to in messageLine.tsx:115. Companion to the streaming 2-column fix in 7242361a — these two touched messageLine.tsx together so they land as a pair. --- ui-tui/src/components/deferredMarkdown.tsx | 90 ++++++++++++++++++++++ ui-tui/src/components/messageLine.tsx | 9 ++- 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/components/deferredMarkdown.tsx diff --git a/ui-tui/src/components/deferredMarkdown.tsx b/ui-tui/src/components/deferredMarkdown.tsx new file mode 100644 index 0000000000..55d984a32a --- /dev/null +++ b/ui-tui/src/components/deferredMarkdown.tsx @@ -0,0 +1,90 @@ +// DeferredMd — renders a lightweight placeholder on first mount and +// upgrades to full markdown + syntax highlighting in a subsequent +// transition commit. Spreads the parse cost off the scroll critical path. +// +// Why: profiling shows the 63-112ms renderer spikes during hold-PageUp +// correlate with fresh MessageLine mounts running the markdown tokenizer +// + syntax highlighting synchronously. The new row is added by +// useVirtualHistory's slide step; React commits the tree; Ink lays out +// Yoga; stdout writes the result. All in one hitch frame. +// +// With this wrapper, the hitch frame lays out a pre-wrapped plain +// (Yoga only needs to wrap width-known strings — no tokenizer, no +// highlighter, no inline regex walk), then a follow-up commit re-renders +// the same row with full markdown. The follow-up is gated on a +// queueMicrotask so Ink has a chance to paint the placeholder before +// React starts the Md-heavy upgrade work. +// +// Upgrade cache: once a given (theme, text, compact) tuple has been +// rendered as full Md, we remember it so remounts (scroll-out then +// scroll-back) don't pay the placeholder round-trip again — they mount +// straight into the upgraded subtree, which Md internally memoizes +// on text identity, so there's no re-tokenization either. + +import { Text } from '@hermes/ink' +import { memo, useEffect, useState } from 'react' + +import type { Theme } from '../theme.js' + +import { Md, stripInlineMarkup } from './markdown.js' + +// Theme object is stable per-session; key upgrades under it so palette +// swaps naturally retrigger (colors differ → render changes). +const upgraded = new WeakMap>() + +const cacheKey = (compact: boolean | undefined, text: string) => (compact ? `c:${text}` : `x:${text}`) + +const hasUpgraded = (t: Theme, key: string) => upgraded.get(t)?.has(key) ?? false + +const markUpgraded = (t: Theme, key: string) => { + const bucket = upgraded.get(t) ?? new Set() + + bucket.add(key) + upgraded.set(t, bucket) +} + +export const DeferredMd = memo(function DeferredMd({ color, compact, t, text }: DeferredMdProps) { + const key = cacheKey(compact, text) + const [ready, setReady] = useState(() => hasUpgraded(t, key) || !text) + + useEffect(() => { + if (ready) { + return + } + + let cancelled = false + + queueMicrotask(() => { + if (cancelled) { + return + } + + markUpgraded(t, key) + setReady(true) + }) + + return () => { + cancelled = true + } + }, [key, ready, t]) + + if (ready) { + return + } + + // Placeholder: strip inline markup so the visible width approximately + // matches the final Md layout (bold/italic/links are width-neutral or + // collapse to anchor text). Line breaks preserved — Ink's wrap="wrap" + // lays the plain text out as blocks at the right column count. + // Using directly (no Box wrapper) so there's no column-flex + // decision for Yoga — it just wraps a string. + return {stripInlineMarkup(text)} +}) + +interface DeferredMdProps { + /** Fallback color for the placeholder text (typically the role's body color). */ + color?: string + compact?: boolean + t: Theme + text: string +} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index a3d3f5844a..fe7c8076a1 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -9,7 +9,7 @@ import { boundedLiveRenderText, compactPreview, hasAnsi, isPasteBackedText, stri import type { Theme } from '../theme.js' import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js' -import { Md } from './markdown.js' +import { DeferredMd } from './deferredMarkdown.js' import { StreamingMd } from './streamingMarkdown.js' import { ToolTrail } from './thinking.js' import { TodoPanel } from './todoPanel.js' @@ -107,7 +107,12 @@ export const MessageLine = memo(function MessageLine({ // streamingMarkdown.tsx for the cost model. ) : ( - + // Deferred markdown: plain-text placeholder on first mount, upgrade + // to full Md on a queued microtask. Spreads the tokenizer + syntax + // cost off the scroll critical path so hold-PageUp doesn't hitch + // on fresh assistant rows entering overscan. See + // deferredMarkdown.tsx for the trade-offs. + ) }