Skip to content

Commit ea48de4

Browse files
CLI - Typewriter streaming rendering
CLI - Typewriter streaming rendering
2 parents e0b3181 + e6a88a5 commit ea48de4

File tree

3 files changed

+386
-9
lines changed

3 files changed

+386
-9
lines changed

.changeset/lovely-views-press.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kilocode/cli": patch
3+
---
4+
5+
Streaming message typewriter rendering
Lines changed: 228 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react"
1+
import React, { useState, useEffect, useRef, useCallback } from "react"
22
import { Text } from "ink"
33
import { parse, setOptions } from "marked"
44
import 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
*/
14111
export 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

Comments
 (0)