High-performance streaming Markdown rendering for Avalonia, built for AI chat clients.
- Streaming first: stable blocks are frozen and parsed once; only the tail is re-parsed. Unfinished syntax (
**bold,`code,[link](ht,$$) is healed while streaming (C# port of vercel's remend). - Native, no WebView: self-drawn text blocks, virtualized transcript, cross-message selection.
- Scientific content: KaTeX-grade LaTeX via the RaTeX engine, Mermaid diagrams, TextMate syntax highlighting.
- NativeAOT-safe: zero trim/AOT warnings is a CI gate.
Status: v0.1 preview — all plan milestones (M0–M7) implemented and integrated into TCode. See docs/architecture.md.
Published to nuget.org as TKCode.StreamMark.*. Assembly and namespace names stay StreamMark.*, so using StreamMark.Avalonia; does not change.
| Package | Purpose |
|---|---|
TKCode.StreamMark.Core |
Streaming engine: segmenter, remend, block model, snapshots/diff, pacer (no UI dependency) |
TKCode.StreamMark.Avalonia |
Avalonia controls and default theme |
TKCode.StreamMark.Highlighting.TextMate |
Incremental syntax highlighting |
TKCode.StreamMark.RaTeX |
LaTeX math via RaTeX (ships the native library for win/linux/osx × x64/arm64) |
TKCode.StreamMark.Mermaid |
Mermaid diagrams |
TKCode.StreamMark.Theme.AtomUI |
AtomUI design-token theme |
Requires the .NET 10 SDK and Avalonia 12. The smallest useful set is the Avalonia package; add the others for highlighting, math, diagrams or the AtomUI theme.
dotnet add package TKCode.StreamMark.Avalonia
dotnet add package TKCode.StreamMark.Highlighting.TextMate
dotnet add package TKCode.StreamMark.RaTeX
dotnet add package TKCode.StreamMark.Mermaid
dotnet add package TKCode.StreamMark.Theme.AtomUI # only when the app already uses AtomUIOr in the project file:
<ItemGroup>
<PackageReference Include="TKCode.StreamMark.Avalonia" Version="0.1.0-alpha.24" />
<PackageReference Include="TKCode.StreamMark.Highlighting.TextMate" Version="0.1.0-alpha.24" />
<PackageReference Include="TKCode.StreamMark.RaTeX" Version="0.1.0-alpha.24" />
<PackageReference Include="TKCode.StreamMark.Mermaid" Version="0.1.0-alpha.24" />
</ItemGroup>TKCode.StreamMark.Avalonia brings in TKCode.StreamMark.Core. The RaTeX package carries ratex_ffi under runtimes/<rid>/native/, and the host picks the library for its own RID; nothing has to be copied by hand. Preview versions need the usual prerelease restore (--prerelease, which dotnet add package does on its own).
Minimal app:
using StreamMark.Avalonia;
using StreamMark.Avalonia.Extensions;
using StreamMark.Highlighting.TextMate;
using StreamMark.Mermaid;
using StreamMark.RaTeX;
public class App : Application
{
public override void Initialize()
{
Styles.Add(new StreamMarkTheme());
// Styles.Add(new AtomUIMarkdownTheme()); // after the host AtomUI theme
}
}
// in the window
var services = new MarkdownServices
{
Plugins = [new RaTeXPlugin(), new TextMatePlugin(), new MermaidPlugin()],
};
MarkdownServices.SetServices(this, services);
Content = new MarkdownView { Markdown = "# Hello **StreamMark**\n\n$E = mc^2$" };// App: default look, then (optionally) map onto AtomUI tokens
Styles.Add(new StreamMarkTheme());
Styles.Add(new AtomUIMarkdownTheme());
// Window: rendering extensions shared by every Markdown view below it
var services = new MarkdownServices
{
Plugins = [new RaTeXPlugin(), new TextMatePlugin(), new MermaidPlugin()],
};
MarkdownServices.SetServices(window, services);
// documents should use the matching dialect so plugin tags are parsed
var entry = MarkdownEntry.FromText(markdown, services.Profile);
// Long chat transcript (virtualized, cross-message selection, stick-to-bottom)
var transcript = new TranscriptView { MaxContentWidth = 760 };
transcript.Entries.Add(new ContentEntry(userBubble)); // any control / data-templated item
var reply = new MarkdownStream(profile: services.Profile); // smoothing + fade-in + caret
transcript.Entries.Add(new MarkdownEntry(reply));
reply.Push(token); /* … */ reply.Complete();
// Single document
var view = new MarkdownView { Markdown = "# Hello" };Mermaid diagrams follow the host's light/dark theme and by default reproduce mermaid.js's own themes (dark / default).
Pick another look with new MermaidPlugin(new MermaidDiagramRenderer { Theme = MermaidTheme.MermaidJsNeutral })
(grayscale light theme) or MermaidTheme.Native (Mermaider's own palette derived from the host colors).
Built in: <details>/<summary>, <think>, <kbd>, <sub>, <sup>, <mark>, <u>, <cite href>, <Citation id="1"/>, <img src width height>.
Plugins add their own: builder.AddBlockComponent("callout", _ => new CalloutView()), builder.AddInlineComponent("badge", renderer).
Raw HTML in Markdown (README style) is converted into the block model, so it supports selection, copying, theming and streaming. The parser is a tolerant one written for this library.
- Block elements:
div p center section article header footer main aside figure h1–h6 table thead tbody tfoot tr th td ul ol li dl dt dd blockquote pre hr details summary. - Inline elements:
a img picture br strong b em i u ins s del strike code kbd samp tt var sub sup mark small big span font abbr cite dfn q time. - Attributes:
align,width,height,bgcolor, and<font color size>. stylesubset:text-align,color,background(-color),font-size,font-weight,font-style,margin*,padding*,border,border-radius,width,max-width,display:none. Values are clamped so content cannot break the layout.- HTML around Markdown: HTML separated from Markdown by blank lines is merged into one box, for example
<div align="center">, a blank line, Markdown, a blank line,</div>. - Unsupported elements: HTML containing an unknown element (including
script,style,iframe) is shown as source and never executed. Comments are hidden. - Author colours:
MarkdownServices.HtmlColorscontrols them. The default isReadable: author colours are used, but the theme colour replaces any colour without enough contrast against its background.ExactandIgnoreare also available.
- Link policy:
MarkdownServices.Links(aLinkPolicy).- By default only
https,httpandmailtolinks are allowed. Relative links and#anchorsare passed to the host. - Blocked links (
javascript:,file:,data:, unknown schemes) render as plain text. LinkClickedEventArgs.RequiresConfirmationis set whenRequireConfirmationis on, or when the link text looks like a different URL (for example[https://github.com](https://evil.example)).TextSegmentView.LinkHoveredEventreports the real URL, for showing in a status bar.
- By default only
- Citations: set
MarkdownServices.Sources(anISourceProvider, for exampleDictionarySourceProvider).- Hovering
<Citation id="1"/>shows a source card with the title, site and snippet; clicking it opens the URL. - An unhandled click on a citation or a
<cite href>raisesLinkClicked.
- Hovering
- Code blocks:
MarkdownServices.CodeBlocks(aCodeBlockOptions).ShowLineNumbers: numbering starts at#L10when the fence info gives a start line.- Collapsing:
CollapseAfterLines(default 40) andCollapsedLines(default 20). Blocks that were streamed are never collapsed. ExpandTextandCollapseTextset the labels.- Download:
ShowDownloadtoggles the button.Saveoverrides the default save dialog. The suggested file name comes from the fence path or the language.
- Diff:
```diffand```patchblocks support three views:- Markdown (default): looks like GitHub's
```diff. The text is shown as is:+lines get a green background,-lines red,@@lines light blue, and file headers are grey. There are no line numbers or file bars. Syntax colours and intra-line change highlights are kept. - Split: the header button toggles it. Old is on the left and new on the right, with line numbers, per-file headers with
+N −Mstats, and paired changes. The choice is kept per block. Split falls back to the default view when the width is belowDiffSplitMinWidth(600). - Unified: line numbers in two columns, chosen with
CodeBlocks.DiffMode = DiffViewMode.Unified. - Diffs render while streaming. Copying gives the original diff text. The tooltip label comes from
CodeBlocks.DiffModeText.
- Markdown (default): looks like GitHub's
- Outline and jumps:
TranscriptView.GetOutline()andMarkdownView.GetOutline()return the headings asOutlineItem(Target, Level, Text, Id). PassTargettoScrollToPoint.TranscriptView.ScrollToPoint(TextPoint)parses deferred entries if needed and places the target at about 30% from the top.MarkdownView.ScrollToPointrelies on the outerScrollViewer.- In-document links (
#heading-id, footnote references#fn:…) jump inside the document and are not passed to the host.
- Source positions and reading anchors (
MarkdownView, block-level precision):- Offsets are UTF-16 code units, the same as
stringindexes andSourceRange: a CJK character counts as 1, a surrogate pair as 2, and\r\nas 2 (the text is not normalised).SourceText.ToLocationandSourceText.ToOffsetconvert to and from 0-based line and column with the same rules. ScrollToSourceOffset(offset)scrolls to the deepest block that contains the offset: a list item, a paragraph inside a quote, or a code block as a whole. It returns aSourceNavigationResult; when nothing moved, the status says why:NoDocument(no snapshot applied yet),OutOfRange, orLayoutPending(try again next frame).GetSourceRange(TextPoint)andGetSelectionSourceRange()map a rendered point or the selection back to the source range of the blocks it covers.TextPoint.Offsetis an offset inside the segment's plain text, not a source offset, so text with emphasis or links never lines up with the source.CaptureAnchor()andRestoreAnchor(anchor)save and restore the reading position: the character at the top of the outerScrollViewerplus its pixel displacement. A width, font or theme change keeps the place. An anchor is tied toDocumentId, which changes wheneverMarkdown,DocumentorStreamis assigned; an anchor from another document returnsWrongDocument. If the anchored block is gone, the view scrolls to the next block (Approximate).
- Offsets are UTF-16 code units, the same as
- Editor previews (
MarkdownView.RefreshMarkdown(text)): replaces the whole text without clearing the view, for a preview next to a source editor where any part of the text can change. AssigningMarkdownstill means "a new document" and resets selection and block state.- The old content stays on screen until the new text has been parsed in the background. Updates that arrive in quick succession (A, B, C) never show A or B once C is accepted.
- The new and old snapshots are aligned by content. Blocks that did not change, or only moved because text was added or removed before them, are updated in place: their views, layout and block state (
BlockStateStore, viaRemap) are kept. - The selection and the reading position move with them. An endpoint inside a rewritten block stays on the paired new block and is clamped to the new text. An endpoint inside a deleted block moves to the start of the next surviving block (or the end of the document); if both ends land on the same point the selection is cleared.
DocumentIddoes not change, so reading anchors captured earlier stay valid.RefreshMarkdownthrows when the text comes fromDocumentorStream.
- Task checkboxes (
MarkdownView, off by default): task list checkboxes are read-only. SetIsTaskInteractionEnabled = trueand a click on the box (or Space / Enter once it has focus) raisesTaskToggleRequestedinstead of changing anything. StreamMark never edits the source or the snapshot: the host decides, edits its own text (with its own undo grouping and saving), and assigns the new text. A rejected request changes nothing on screen.TaskToggleRequestedEventArgscarriesMarker(the source range of[ ]/[x]),IsChecked,RequestedChecked,DocumentIdandVersion.ApplyTo(source)replaces only the character inside the brackets, so indentation and CRLF are kept; it returnsnullif the source no longer matches.- Requests are raised only once the document is complete, not while a stream is still running. If the host handles a request asynchronously,
view.IsCurrent(request)tells whether the document and snapshot are still the ones the request was made on. - Clicking the task text still selects text. The list item exposes a
CheckBoxautomation peer withIToggleProvider;Toggle()raises the same request.
- Search:
view.Searchis aSearchModel.Find(query, matchCase)highlights every match, including content scrolled off-screen.Next(),Previous()andMoveTo(i)jump to a match.Clear()removes the highlights.- Colours come from
Md.SearchHighlightBackgroundandMd.SearchCurrentBackground.
- Keyboard selection: with a selection or caret, Shift+←/→ moves by character (Alt or Ctrl moves by word) across segments, Shift+↑/↓ moves by line, and Shift+Home/End goes to the segment start or end.
- Context menu: Copy, Copy as Markdown, Select All, and Find.
- It uses
MenuFlyout, so it relies on the host theme's templates. - The labels come from
MarkdownServices.MenuText. Choosing "Find" raisesMarkdownContextMenu.FindRequestedEventso the host can show its find box. - Disable it with
ContextMenu = false.
- It uses
- Fullscreen:
- Large or overflowing tables get a fullscreen button that opens a
BlockOverlayat natural column widths. - Rendered diagrams open in the zoomable viewer.
- Large or overflowing tables get a fullscreen button that opens a
- Accessibility:
- Text segments, code and diffs expose Text automation peers whose name is the plain text. Headings carry their heading level.
- Icon buttons are Buttons with Invoke support. Block views are Group, Table or List. Markdown and transcript views are Documents.
- Avalonia has no public text pattern, so screen readers only get the name.
var document = new StreamingDocument();
document.SnapshotReady += _ => RequestFrame(); // raised on a background thread
// network callback
document.Append(token);
// end of stream
document.Complete();
// on each UI frame
var next = document.Latest;
foreach (var op in SnapshotDiff.Compute(applied, next)) { /* insert / update / remove block views */ }
applied = next;git submodule update --init
./native/build.sh # builds the RaTeX native library for this machine (needs Rust)
./native/cross-build.sh # or: all six desktop RIDs from one macOS machine (rustup targets + zig + cargo-zigbuild)
dotnet build StreamMark.slnx
dotnet test StreamMark.slnx
dotnet publish samples/StreamMark.Gallery -c Release -r osx-arm64 && ./samples/StreamMark.Gallery/bin/Release/net10.0/osx-arm64/publish/StreamMark.Gallery --smoketools/pack-local.sh 0.1.0-alpha.5 # writes artifacts/packages/*.nupkg (bump the version on every repack)MIT. See THIRD-PARTY-NOTICES.md.