diff --git a/ui-tui/src/components/deferredMarkdown.tsx b/ui-tui/src/components/deferredMarkdown.tsx new file mode 100644 index 00000000000..55d984a32a5 --- /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 a3d3f5844ab..fe7c8076a17 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. + ) }