1- import React from "react"
1+ import React , { useState , useEffect , useRef , useCallback } from "react"
22import { Text } from "ink"
33import { parse , setOptions } from "marked"
44import TerminalRenderer , { type TerminalRendererOptions } from "marked-terminal"
@@ -8,12 +8,232 @@ export type MarkdownTextProps = TerminalRendererOptions & {
88}
99
1010/**
11- * Wrapper component for rendering markdown text in Ink
12- * Falls back to plain Text if markdown parsing fails
11+ * Calculate adaptive animation speed based on chunk timing and remaining unrendered text
12+ * Returns both interval duration and characters per update to match streaming speed
13+ *
14+ * @param timeSinceLastChunk - Milliseconds since last chunk arrived
15+ * @param remainingChars - Number of characters still to be rendered (not just new chunk)
16+ * @returns Object with intervalMs (update frequency) and charsPerUpdate (chars per interval)
17+ */
18+ const calculateAdaptiveSpeed = (
19+ timeSinceLastChunk : number ,
20+ remainingChars : number ,
21+ ) : { intervalMs : number ; charsPerUpdate : number } => {
22+ // Calculate how much time we have per character based on remaining work
23+ const timePerChar = timeSinceLastChunk / Math . max ( remainingChars , 1 )
24+
25+ // Use 98% of available time to aggressively match streaming speed
26+ const targetTimePerChar = timePerChar * 0.98
27+
28+ // Keep update intervals smooth but allow faster updates
29+ let intervalMs : number
30+ let charsPerUpdate : number
31+
32+ if ( targetTimePerChar < 8 ) {
33+ // Very fast streaming: show many chars per update
34+ intervalMs = 8
35+ charsPerUpdate = Math . max ( 1 , Math . ceil ( intervalMs / targetTimePerChar ) )
36+ } else if ( targetTimePerChar < 15 ) {
37+ // Fast streaming: show 2-3 chars per update
38+ intervalMs = 10
39+ charsPerUpdate = Math . max ( 2 , Math . ceil ( intervalMs / targetTimePerChar ) )
40+ } else if ( targetTimePerChar > 30 ) {
41+ // Slow streaming: one char per slower update
42+ intervalMs = Math . min ( targetTimePerChar , 40 )
43+ charsPerUpdate = 1
44+ } else {
45+ // Normal streaming: one char per update at natural pace
46+ intervalMs = targetTimePerChar
47+ charsPerUpdate = 1
48+ }
49+
50+ return { intervalMs, charsPerUpdate }
51+ }
52+
53+ /**
54+ * MarkdownText Component with Adaptive Typewriter Effect
55+ *
56+ * A wrapper component that renders markdown text in Ink terminals with an intelligent
57+ * typewriter animation that adapts to streaming content speed.
58+ *
59+ * ## Features
60+ *
61+ * ### Adaptive Speed Animation
62+ * The typewriter effect dynamically adjusts speed based on ALL remaining unrendered text:
63+ *
64+ * - **Speed calculation**: Uses time since last chunk ÷ remaining unrendered characters
65+ * - **Dynamic adjustment**: Recalculates on each new chunk to catch up if needed
66+ * - **Very fast** (< 8ms/char): Shows many characters per 8ms update
67+ * - **Fast** (8-15ms/char): Shows 2-3 characters per 10ms update
68+ * - **Normal** (15-30ms/char): Shows 1 character per update at natural pace
69+ * - **Slow** (> 30ms/char): Shows 1 character per slower update (max 40ms)
70+ *
71+ * This ensures the animation stays synchronized even with variable chunk speeds:
72+ * - If first chunk is slow, starts slow
73+ * - If next chunk arrives faster, speeds up to catch up with ALL remaining text
74+ * - Uses 98% of available time per chunk to stay synchronized
75+ * - No content gets "dumped" at the end when streaming stops
76+ *
77+ * ### Intelligent Chunk Detection
78+ * - Automatically detects when new content is appended (streaming scenario)
79+ * - Handles complete content replacement (new message scenario)
80+ * - Shows initial content immediately without animation
81+ *
82+ * ### Performance Optimizations
83+ * - Markdown parsing occurs once per displayed text update (not per character)
84+ * - Uses refs to track state without causing unnecessary re-renders
85+ * - Efficient timer cleanup on unmount
86+ *
87+ * ### Edge Case Handling
88+ * - Empty or whitespace content: Returns null immediately
89+ * - Content replacement: Shows new content right away
90+ * - Rapid updates: Continues animation smoothly through new chunks
91+ *
92+ * ## Usage
93+ *
94+ * ```tsx
95+ * // Basic usage
96+ * <MarkdownText>**Hello** World</MarkdownText>
97+ *
98+ * // With streaming updates
99+ * <MarkdownText>{streamingContent}</MarkdownText>
100+ *
101+ * // With TerminalRenderer options
102+ * <MarkdownText width={80} reflowText={true}>
103+ * # Heading
104+ * </MarkdownText>
105+ * ```
106+ *
107+ * @param children - The markdown content to render
108+ * @param options - Optional TerminalRenderer configuration
109+ * @returns Rendered markdown text with typewriter animation for streaming content
13110 */
14111export const MarkdownText : React . FC < MarkdownTextProps > = ( { children, ...options } ) => {
15- // If the text is empty or just whitespace, don't render anything
16- if ( ! children || ! children . trim ( ) ) {
112+ // State for displayed text (what user sees)
113+ const [ displayedText , setDisplayedText ] = useState ( "" )
114+
115+ // Refs for tracking without causing re-renders
116+ const previousContentRef = useRef ( "" )
117+ const lastChunkTimeRef = useRef ( Date . now ( ) )
118+ const animationTimerRef = useRef < NodeJS . Timeout | null > ( null )
119+ const targetTextRef = useRef ( "" )
120+ const currentIndexRef = useRef ( 0 )
121+ const charsPerUpdateRef = useRef ( 1 ) // How many characters to show per interval
122+
123+ // Cleanup animation timer on unmount
124+ useEffect ( ( ) => {
125+ return ( ) => {
126+ if ( animationTimerRef . current ) {
127+ clearInterval ( animationTimerRef . current )
128+ }
129+ }
130+ } , [ ] )
131+
132+ /**
133+ * Start animation at the specified interval, showing N characters per update
134+ */
135+ const startAnimation = useCallback ( ( intervalMs : number ) => {
136+ if ( animationTimerRef . current ) {
137+ clearInterval ( animationTimerRef . current )
138+ }
139+
140+ animationTimerRef . current = setInterval ( ( ) => {
141+ if ( currentIndexRef . current < targetTextRef . current . length ) {
142+ // Show N characters per update based on streaming speed
143+ currentIndexRef . current = Math . min (
144+ currentIndexRef . current + charsPerUpdateRef . current ,
145+ targetTextRef . current . length ,
146+ )
147+ setDisplayedText ( targetTextRef . current . slice ( 0 , currentIndexRef . current ) )
148+ } else {
149+ // Animation complete - clear the timer
150+ if ( animationTimerRef . current ) {
151+ clearInterval ( animationTimerRef . current )
152+ animationTimerRef . current = null
153+ }
154+ }
155+ } , intervalMs )
156+ } , [ ] )
157+
158+ // Handle new content arrival
159+ useEffect ( ( ) => {
160+ // If the text is empty or just whitespace, reset state
161+ if ( ! children || ! children . trim ( ) ) {
162+ setDisplayedText ( "" )
163+ previousContentRef . current = ""
164+ targetTextRef . current = ""
165+ currentIndexRef . current = 0
166+ if ( animationTimerRef . current ) {
167+ clearInterval ( animationTimerRef . current )
168+ animationTimerRef . current = null
169+ }
170+ return
171+ }
172+
173+ // Check if content has actually changed
174+ if ( children === previousContentRef . current ) {
175+ return
176+ }
177+
178+ // First render - show content immediately without animation
179+ if ( previousContentRef . current === "" ) {
180+ setDisplayedText ( children )
181+ currentIndexRef . current = children . length
182+ targetTextRef . current = children
183+ previousContentRef . current = children
184+ return
185+ }
186+
187+ // Detect if this is a new chunk (content grew and old content is a prefix)
188+ const isNewChunk =
189+ children . length > previousContentRef . current . length && children . startsWith ( previousContentRef . current )
190+
191+ if ( isNewChunk ) {
192+ // New chunk detected - calculate adaptive speed
193+ const now = Date . now ( )
194+ const timeSinceLastChunk = now - lastChunkTimeRef . current
195+ lastChunkTimeRef . current = now
196+
197+ // Update target text
198+ targetTextRef . current = children
199+
200+ // Calculate remaining unrendered text
201+ // This is key: we base speed on ALL remaining text, not just the new chunk
202+ const remainingChars = children . length - currentIndexRef . current
203+
204+ // Calculate adaptive speed and chars per update based on remaining work
205+ const { intervalMs, charsPerUpdate } = calculateAdaptiveSpeed ( timeSinceLastChunk , remainingChars )
206+ charsPerUpdateRef . current = charsPerUpdate
207+
208+ // If not currently animating, start animation from current position
209+ if ( ! animationTimerRef . current ) {
210+ startAnimation ( intervalMs )
211+ } else {
212+ // Already animating - restart with new speed for remaining text
213+ clearInterval ( animationTimerRef . current )
214+ startAnimation ( intervalMs )
215+ }
216+ } else {
217+ // Content changed completely (not a chunk append) - show immediately
218+ setDisplayedText ( children )
219+ currentIndexRef . current = children . length
220+ targetTextRef . current = children
221+ if ( animationTimerRef . current ) {
222+ clearInterval ( animationTimerRef . current )
223+ animationTimerRef . current = null
224+ }
225+ }
226+
227+ previousContentRef . current = children
228+ } , [ children , startAnimation ] )
229+
230+ // Determine what text to actually display
231+ // On initial render before effect runs, use children directly
232+ // After that, use the animated displayedText
233+ const textToDisplay = displayedText || children
234+
235+ // If nothing to display, return null
236+ if ( ! textToDisplay || ! textToDisplay . trim ( ) ) {
17237 return null
18238 }
19239
@@ -23,11 +243,11 @@ export const MarkdownText: React.FC<MarkdownTextProps> = ({ children, ...options
23243 renderer : new TerminalRenderer ( options ) ,
24244 } )
25245
26- // Parse markdown and render with terminal-friendly formatting
27- const rendered = parse ( children ) as string
246+ // Parse markdown on the displayed text (efficient - only once per update)
247+ const rendered = parse ( textToDisplay ) as string
28248 return < Text > { rendered . trim ( ) } </ Text >
29249 } catch {
30250 // Fallback to plain text if markdown parsing fails
31- return < Text > { children } </ Text >
251+ return < Text > { textToDisplay } </ Text >
32252 }
33253}
0 commit comments