flutter_markdown_stream 0.5.0
flutter_markdown_stream: ^0.5.0 copied to clipboard
Flicker-free streaming Markdown widget for Flutter. Renders partial LLM token streams progressively, handling unclosed syntax gracefully.
flutter_markdown_stream #
Flicker-free, crash-safe streaming Markdown widget for Flutter. Drop it into your chat UI, point it at a Stream<String> of LLM token chunks, and get smooth, progressive Markdown rendering — even when the syntax is half-typed.
Demo #
A simulated LLM response streamed token-by-token. Bold, italic, fenced code, lists, and links all render progressively without flicker, even while their closing syntax is still in flight.
Why? #
flutter_markdown_plus parses each rebuild from scratch. When an LLM emits **bold before the closing **, or opens a ```dart fence before the rest of the code arrives, the widget either throws, flashes, or renders the remainder of your document as code until the closing token appears.
flutter_markdown_stream fixes that with a small sanitizer that projects the current buffer into a syntactically-safe form at render time, then falls back to the raw buffer once the stream completes.
Features #
- Handles unclosed bold, italic, strikethrough, inline code, fenced code blocks, autolinks, inline links, incomplete GFM tables, and LaTeX delimiters mid-stream — with CommonMark-correct flanking rules, so bullet lists, thematic breaks, and stray
*/$in prose are never mangled. - One-frame debounce coalesces bursts of tokens into a single rebuild, plus optional output smoothing that paces bursty tokens into an even, ChatGPT/Claude-style flow.
- Opt-in
AutoScrollstick-to-bottom for chat UIs, modelled on Claude mobile (follows the bottom, disengages when you scroll up, never yanks). - Ready-made
CodeBlockViewwith a language label and copy button, or bring your owncodeBuilder. - Optional incremental parsing for long answers — settled blocks are parsed once, not re-parsed every frame.
- Eight cursor widgets, all screen-reader friendly (
ExcludeSemantics+ optionalsemanticLabel). onTextChangedfor live progress, and aStreamingTextControllerfor pause / resume / skip / restart.- Pure Dart sanitizer — fully unit-tested, zero platform channels.
Install #
dependencies:
flutter_markdown_stream: ^0.5.0
Usage #
import 'package:flutter/material.dart';
import 'package:flutter_markdown_stream/flutter_markdown_stream.dart';
class ChatBubble extends StatelessWidget {
const ChatBubble({super.key, required this.stream});
final Stream<String> stream;
@override
Widget build(BuildContext context) {
return MarkdownStream(
stream: stream,
onDone: (fullText) => debugPrint('Finished: $fullText'),
cursorWidget: const BlinkingCursor(),
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
codeBuilder: (code, language) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
child: Text(
code,
style: const TextStyle(
color: Colors.greenAccent,
fontFamily: 'monospace',
),
),
),
);
}
}
Streaming feel & chat-UI helpers (0.5.0) #
Smooth by default #
Since 0.5.0, MarkdownStream smooths and word-fades out of the box — the
ChatGPT/Claude feel with no configuration. For instant, un-animated text:
MarkdownStream(
stream: llmStream,
wordFadeIn: false,
config: StreamingPresets.instant,
);
Per-word fade-in (wordFadeIn) #
The words in the paragraph currently being streamed fade in (opacity only, no blur) as they arrive — settled text stays full-fidelity, and everything is opaque once the stream completes.
MarkdownStream(stream: llmStream); // on by default
MarkdownStream(stream: llmStream, wordFadeWindow: 6); // longer, softer fade
It applies to prose (with inline bold/italic/code/links); code blocks, tables, lists, headings, and blockquotes stream without a fade.
Output smoothing #
Debounce only coalesces bursty tokens; smoothing paces them, so text flows at a steady rate no matter how lumpy the network delivery is. It's on by default; tune or swap the feel with a config or preset:
MarkdownStream(stream: llmStream, config: StreamingPresets.claude); // or .chatGPT / .smooth
MarkdownStream(
stream: llmStream,
config: const StreamingTextConfig(smoothingEnabled: true, charsPerSecond: 140),
);
Auto-scroll (opt-in, Claude-mobile behaviour) #
Nothing scrolls unless you ask. Wrap your streaming content in AutoScroll and
it follows the bottom while you're already there, disengages the instant you
scroll up to read, and re-engages when you return — it never yanks you down.
Following is automatic: AutoScroll watches the scrollable's
ScrollMetricsNotification, so it re-follows every time the content grows —
including while a MarkdownStream streams tokens inside it. You do not wire
up a trigger.
AutoScroll(
child: Column(
children: [
for (final message in messages) MessageBubble(message),
MarkdownStream(stream: llmStream), // grows → AutoScroll follows
],
),
);
Set enabled: false at any time to freeze following without unwrapping.
StickToBottomController
AutoScroll owns its scroll + stick-to-bottom controllers internally. Supply
your own StickToBottomController when you need to read its state or drive it
from elsewhere in the tree — e.g. to render a "jump to latest" button:
final stick = StickToBottomController();
AutoScroll(controller: stick, child: messageColumn);
// Elsewhere: show a button only while the user has scrolled away.
ValueListenableBuilder<bool>(
valueListenable: stick.showScrollToBottomButtonListenable,
builder: (context, show, _) => show
? FloatingActionButton.small(
onPressed: stick.jumpToBottom, // not gated by `enabled`
child: const Icon(Icons.arrow_downward),
)
: const SizedBox.shrink(),
);
Key surface:
| Member | What it does |
|---|---|
bool get isPinnedToBottom |
Whether the view is within threshold (default 32px) of the bottom. Tracks reality even while enabled == false. |
pinnedToBottomListenable |
ValueListenable<bool> of the above. |
showScrollToBottomButtonListenable |
ValueListenable<bool> — true only when enabled and not pinned. Render your own button from it. |
follow({animate}) |
Follow to the bottom if enabled and currently pinned. Called automatically on content growth. |
jumpToBottom() / animateToBottom() |
Imperative scroll to bottom and re-pin. Not gated by enabled (this is a user tapping "jump"). |
enabled (get/set) |
Runtime toggle. false = transparent passthrough: no following, no button state, never fights the user. |
You can also attach a StickToBottomController to your own ScrollView
(pass its scrollController) instead of using AutoScroll.
Code blocks with a copy button #
MarkdownStream(
stream: llmStream,
codeBuilder: CodeBlockView.builder(), // language label + copy button
);
For syntax highlighting, pass a highlightBuilder — bring your own tokenizer
(e.g. package:highlight / package:flutter_highlight) so this package stays
dependency-free:
CodeBlockView.builder(
highlightBuilder: (code, language, baseStyle) {
// return an InlineSpan (TextSpan tree) with per-token colours
return myHighlighter.toTextSpan(code, language, baseStyle);
},
);
Code blocks render full width (with the copy control at the top-right) when the
surrounding MarkdownStream fills its width — pass fitContent: false if your
layout otherwise shrink-wraps.
Incremental parsing (long answers) #
Re-parsing the whole buffer every frame is O(n²) over a long response. Turn on incremental parsing to parse settled blocks once and reuse them — the settled output is identical, it's a pure performance switch.
MarkdownStream(stream: llmStream, incrementalParsing: true);
Accessible cursors #
MarkdownStream(
stream: llmStream,
cursorWidget: const TypingDotsCursor(semanticLabel: 'Assistant is typing'),
);
Edge cases handled #
| Case | Input mid-stream | Rendered safely as |
|---|---|---|
| Unclosed bold | This is **bold |
This is **bold** |
Unclosed italic (* or _) |
so *cool |
so *cool* |
| Unclosed fenced code | ```dart\nfinal x |
```dart\nfinal x\n``` |
| Unclosed inline code | run \dart test` |
run \dart test`` |
| Partial inline link | see [docs](ht |
see |
| Partial autolink | visit <https://exa |
visit |
| Unclosed strikethrough | gone ~~away |
gone ~~away~~ |
| Intra-word underscore | snake_case_var |
left alone |
| CRLF / lone CR line endings | a\r\nb |
a\nb |
See test/safe_markdown_parser_test.dart for the exhaustive matrix.
Architecture #
The widget keeps two strings:
- Raw buffer — the exact concatenation of every chunk received. This is what
onDonereports and what's used for the final render once the stream closes. - Rendered projection — the sanitized form, produced by
SafeMarkdownParser.sanitize(raw)on every debounced rebuild.
Sanitization is non-destructive: once the closing token (e.g. **) arrives in a later chunk, re-sanitizing the now-complete raw buffer naturally produces the correct Markdown, and the synthetic closer added on the previous frame disappears.
Rebuilds are throttled with a Timer set to rebuildDebounce (default one frame ≈ 16ms). This eliminates the flicker and layout jumps you'd otherwise see when dozens of tokens per second each trigger a rebuild.
Cursor widgets #
Eight built-in cursors, all with the same constructor shape
(color, size knobs, period) so you can swap them freely:
| Widget | What it looks like |
|---|---|
BlinkingCursor |
Square-wave block; the classic. |
BarCursor |
Thin I-beam (traditional text cursor). |
FadingCursor |
Block that fades in/out sinusoidally — no hard edges. |
PulsingCursor |
Circular dot that breathes. |
TypingDotsCursor |
Three dots activating in sequence (iMessage style). |
WaveDotsCursor |
Three dots bouncing in a wave. |
SpinnerCursor |
Small circular spinner. |
ShimmerCursor |
Bar with a highlight sliding across it. |
MarkdownStream(
stream: ...,
cursorWidget: const PulsingCursor(color: Colors.indigo),
)
All cursors default to the ambient DefaultTextStyle colour so they match
your theme automatically. Each manages its own AnimationController and
disposes it cleanly on unmount — drop them anywhere, including inside
ListView.builder items.
You can also supply any custom widget — cursorWidget accepts anything.
Non-string streams #
MarkdownStream is generic over the chunk type. The common case is
Stream<String> — just pass it, no extractor needed:
MarkdownStream(stream: plainStringStream)
For any other chunk type, supply a chunkToText function. A few common
shapes:
Typed SDK chunks (openai_dart, anthropic_sdk_dart, etc.) #
MarkdownStream<CreateChatCompletionStreamResponse>(
stream: client.createChatCompletionStream(request: ...),
chunkToText: (chunk) => chunk.choices.first.delta?.content ?? '',
)
Raw HTTP bytes → SSE → JSON delta #
Chain the three StreamAdapters — they're composable:
final http.StreamedResponse response = await client.send(request);
final text = StreamAdapters.jsonField(
StreamAdapters.serverSentEvents(
StreamAdapters.utf8Bytes(response.stream),
),
<Object>['choices', 0, 'delta', 'content'],
);
MarkdownStream(stream: text, cursorWidget: const BlinkingCursor())
Anthropic Messages API #
final text = StreamAdapters.jsonField(
StreamAdapters.serverSentEvents(
StreamAdapters.utf8Bytes(response.stream),
),
<Object>['delta', 'text'],
);
Pre-parsed JSON events #
MarkdownStream<Map<String, dynamic>>(
stream: jsonEventStream,
chunkToText: (event) => event['delta']?['text'] as String? ?? '',
)
What's in StreamAdapters #
| Adapter | Does |
|---|---|
utf8Bytes |
Decodes Stream<List<int>> as UTF-8, safe across byte splits. |
serverSentEvents |
Parses SSE. Buffers lines across chunks. Honours [DONE]. |
jsonField |
Decodes each chunk as JSON and extracts a nested string path. |
For anything else, Stream.map() is always the right tool.
Customization #
MarkdownStream is a superset of flutter_markdown_plus's MarkdownBody —
every hook exposed by MarkdownBody is forwarded verbatim. If you can
style, theme, or customize it with MarkdownBody, you can do the same
thing with MarkdownStream.
Stream-specific parameters #
| Parameter | Purpose |
|---|---|
stream |
Stream<String> of token chunks (required). |
onDone |
Called with the full raw text when the stream ends. |
cursorWidget |
Shown at the tail while streaming. |
rebuildDebounce |
Coalesces bursts of tokens; default 16ms. |
codeBuilder |
Sugar for a block-code builder. See below. |
All MarkdownBody parameters, passed through #
styleSheet, styleSheetTheme, syntaxHighlighter, onTapLink,
onTapText, imageDirectory, blockSyntaxes, inlineSyntaxes,
extensionSet, imageBuilder, checkboxBuilder, bulletBuilder,
builders, paddingBuilders, listItemCrossAxisAlignment,
fitContent, shrinkWrap, softLineBreak, selectable, padding.
Heavily-customised example #
MarkdownStream(
stream: llmResponseStream,
onDone: (text) => debugPrint('Done: $text'),
cursorWidget: const BlinkingCursor(),
rebuildDebounce: const Duration(milliseconds: 16),
// Theming
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
h1: Theme.of(context).textTheme.displaySmall,
code: const TextStyle(fontFamily: 'FiraCode', backgroundColor: Colors.black12),
),
// Custom code block (syntax highlighting, copy button, etc.)
codeBuilder: (code, language) => MyCodeBlock(code: code, language: language),
// Custom image loading (e.g. cached_network_image)
imageBuilder: (uri, title, alt) => CachedNetworkImage(imageUrl: uri.toString()),
// Custom checkbox for GFM task lists
checkboxBuilder: (checked) => Icon(checked ? Icons.check_box : Icons.check_box_outline_blank),
// Custom bullet rendering
bulletBuilder: (params) => Text('→ ', style: TextStyle(color: Colors.teal)),
// GFM extensions (tables, task lists, strikethrough)
extensionSet: md.ExtensionSet.gitHubFlavored,
// Full control for any element — takes precedence over codeBuilder
builders: {
'my-custom-tag': MyCustomElementBuilder(),
'h1': MyH1Builder(),
},
// Per-tag padding overrides
paddingBuilders: {
'blockquote': MyBlockquotePaddingBuilder(),
},
// Layout knobs
listItemCrossAxisAlignment: MarkdownListItemCrossAxisAlignment.start,
softLineBreak: true,
shrinkWrap: true,
selectable: true,
padding: const EdgeInsets.all(16),
onTapLink: (text, href, title) => launchUrl(Uri.parse(href!)),
)
codeBuilder vs builders['code'] #
codeBuilderis sugar — it fires only for block fenced code (elements with alanguage-*class), never for inline`code`.- If you need to intercept inline code, element-level attributes, or any
other tag, use
buildersdirectly. Abuilders['code']entry takes precedence overcodeBuilder.
License #
MIT — see LICENSE.