Streaming
Rendering a reply while the model is still generating it. Streaming in gpt_markdown is data, not a Stream — rebuild the widget with a longer string as tokens arrive, and say whether more is coming via isStreaming.
Accumulating text
Buffer incoming tokens in a StringBuffer, call setState on each chunk, and flip isStreaming to false on completion:
1// Streaming is data, not a Dart Stream.
2// Rebuild the widget with a longer string as tokens arrive.
3class ReplyView extends StatefulWidget {
4 const ReplyView({super.key, required this.stream});
5 final Stream<String> stream; // your transport layer
6
7
8 State<ReplyView> createState() => _ReplyViewState();
9}
10
11class _ReplyViewState extends State<ReplyView> {
12 final _buffer = StringBuffer();
13 bool _generating = true;
14
15
16 void initState() {
17 super.initState();
18 widget.stream.listen(
19 (chunk) => setState(() => _buffer.write(chunk)),
20 onDone: () => setState(() => _generating = false),
21 onError: (_) => setState(() => _generating = false), // always flip on error
22 );
23 }
24
25
26 Widget build(BuildContext context) => SingleChildScrollView(
27 padding: const EdgeInsets.all(16),
28 child: GptMarkdown(
29 _buffer.toString(),
30 animation: GptMarkdownAnimation.fade,
31 isStreaming: _generating,
32 ),
33 );
34}Animation modes
GptMarkdownAnimation.none is the default and costs nothing extra — no ticker, no wrapper widget. Use GptMarkdownAnimation.fade for a smooth reveal:
1// GptMarkdownAnimation.none — the default.
2// No ticker, no wrapper, no split: the tree is exactly what it was before
3// the streaming feature existed. Use for history messages.
4GptMarkdown(message.text)
5
6// GptMarkdownAnimation.fade — fade in newly revealed characters.
7// The settled prefix is cached; only the live tail rebuilds.
8GptMarkdown(
9 message.text,
10 animation: GptMarkdownAnimation.fade,
11 isStreaming: message.isGenerating,
12)isStreaming lifecycle
isStreaming the widget cannot tell whether the text got longer (new token) or replaced (regenerate). It also cannot know when to fast-forward the remaining reveal.1// isStreaming tells the widget two things:
2// true → more text is coming; keep the reveal animation running
3// false → the reply is complete; fast-forward any remaining reveal
4//
5// Without it the widget cannot distinguish:
6// • text got longer → new token, continue revealing
7// • text got replaced → regenerate/branch, restart reveal
8//
9// Static history messages render with zero animation cost:
10GptMarkdown(
11 message.text,
12 animation: GptMarkdownAnimation.fade,
13 isStreaming: message.isGenerating, // false for everything already sent
14)Advanced streaming behavior show performance and edge cases
Performance
The source text is split at the last safe blank line. The settled prefix is built once and cached behind a RepaintBoundary; only the live tail (at most one construct) rebuilds. Measured on a 7.7 kB reply, 120 appends:
| Mode | Cost per token | Notes |
|---|---|---|
| animation: none | 14.6 ms / token | Rebuilds whole document. Gets worse as reply grows. |
| animation: fade | 11.0 ms / token | Settled prefix cached. Stayed flat in the measured reply. |
The fade path is faster with the animation because the split avoided rebuilding the whole document in this benchmark. Results depend on source shape and device; treat these numbers as a measured example rather than a universal guarantee.
charactersPerSecond pacing
charactersPerSecond (default 300) is a baseline, not a cap. Pick the speed that reads well when the model is slower than the reveal — lag adaptation handles the opposite case automatically.
1// charactersPerSecond (default: 300) is the baseline reveal speed.
2// Three behaviours layer on top of it automatically:
3//
4// 1. Lag adaptation — if the backlog would take > 0.4 s, the reveal
5// speeds up to clear it in that window. The animation never falls
6// further behind on a long reply.
7//
8// 2. Fast-forward — when isStreaming turns false, the remainder
9// lands within 0.15 s. A finished reply never trickles.
10//
11// 3. Restart — text that replaces rather than extends restarts the
12// reveal (regenerate or branch switch).
13GptMarkdown(
14 reply,
15 animation: GptMarkdownAnimation.fade,
16 isStreaming: generating,
17 charactersPerSecond: 220, // calmer default; adaptation still applies
18)Reduced motion
When MediaQuery.disableAnimations is true, the reveal is skipped and the finished document renders immediately. No ticker runs. Nothing to configure — but test it, because it is the path users with motion sensitivity get:
1// The widget honours MediaQuery.disableAnimations.
2// When true, the reveal is skipped and the document renders immediately.
3// No ticker runs — the fast path is taken regardless of 'animation'.
4//
5// Test this path explicitly, because it is what users with motion
6// sensitivity get in production:
7MediaQuery(
8 data: const MediaQueryData(disableAnimations: true),
9 child: GptMarkdown(
10 reply,
11 animation: GptMarkdownAnimation.fade,
12 isStreaming: generating,
13 ),
14)Incomplete code fences
The split never cuts inside a fenced code block. An unterminated fence in the settled prefix would render as literal text until the closing fence arrived — a visible flicker. Use the closed flag in codeBuilder to show a lighter UI mid-stream:
1// While closed = false on a code block, the closing fence hasn't arrived.
2// gpt_markdown never cuts the settled prefix inside a code fence —
3// a split inside an unterminated fence would render as literal text
4// and cause a visible flicker.
5//
6// Use the closed flag in codeBuilder to show a lighter UI mid-stream:
7GptMarkdown(
8 reply,
9 animation: GptMarkdownAnimation.fade,
10 isStreaming: generating,
11 codeBuilder: (context, name, code, closed) {
12 if (!closed) {
13 return Container(
14 width: double.infinity,
15 padding: const EdgeInsets.all(12),
16 color: Colors.grey.shade900,
17 child: Text(
18 code,
19 style: const TextStyle(fontFamily: 'monospace', color: Colors.white70),
20 ),
21 );
22 }
23 return MyHighlightedCodeBlock(language: name, code: code);
24 },
25)Fast-forward without visible animation
To get the split-document caching without a visible reveal, use a very high charactersPerSecond. The reveal finishes within a single frame while the prefix stays cached:
1// Set a very high charactersPerSecond to get the split-document caching
2// without a visible reveal animation. The prefix stays cached while the
3// reveal completes within a single frame.
4//
5// Useful when you want the performance benefit of the split but a
6// 'no animation' appearance.
7GptMarkdown(
8 reply,
9 animation: GptMarkdownAnimation.fade,
10 isStreaming: generating,
11 charactersPerSecond: 100000, // finishes within one frame
12)Selection after reveal
The live tail is rebuilt every frame while revealing, so it is not a stable selection target. Selection returns the moment isStreaming becomes false and the fast-forward completes. History messages with isStreaming: false are fully selectable.
1// Selection is unavailable on the live tail while the reveal runs.
2// The tail is rebuilt every frame, so it is not a stable selection target.
3// Selection returns the moment isStreaming becomes false and the
4// fast-forward completes.
5//
6// Static history messages are fully selectable — wrap the whole chat in
7// a single SelectionArea:
8SelectionArea(
9 child: ListView.builder(
10 itemBuilder: (ctx, i) => GptMarkdown(
11 messages[i].text,
12 animation: GptMarkdownAnimation.fade,
13 isStreaming: messages[i].isGenerating,
14 ),
15 ),
16)Chat list and scroll pitfalls
ListView above rebuilds every bubble. Make the generating message itself listenable so only that bubble rebuilds.isStreaming: true after the reply finishes. The ticker keeps running and the tail keeps rebuilding for nothing. Always flip it in onDone — including error paths.1// The split keeps *this widget* cheap. It cannot help if the parent
2// ListView rebuilds every bubble on each token.
3//
4// Make only the generating message listenable:
5class _ChatListState extends State<ChatList> {
6 // Only _messages[_generatingIndex] changes during streaming —
7 // use a ValueNotifier or Riverpod so the rest of the list stays stable.
8
9 Widget build(BuildContext context) {
10 return ListView.builder(
11 itemCount: _messages.length,
12 itemBuilder: (ctx, i) {
13 final msg = _messages[i];
14 return GptMarkdown(
15 msg.text,
16 animation: GptMarkdownAnimation.fade,
17 isStreaming: msg.isGenerating,
18 );
19 },
20 );
21 }
22}
23
24// Auto-scrolling pitfall:
25// The reply grows continuously — jumping to the bottom on every token
26// fights the reveal animation. Instead, animate to the extent, or
27// only pin-scroll while the user is already at the bottom.