For a chat window, give each message its own viewer, include the Chat preset, and stream each answer into its viewer as it arrives:
<!-- App.axaml -->
<StyleInclude Source="avares://MarkView.Avalonia/Themes/MarkdownTheme.axaml" />
<StyleInclude Source="avares://MarkView.Avalonia/Themes/ChatTheme.axaml" />
async Task ShowAnswerAsync(MarkdownViewer viewer, IAsyncEnumerable<string> tokens)
{
viewer.Markdown = "";
using var session = viewer.BeginStreaming();
await foreach (var token in tokens)
session.Append(token);
} // leaving the block completes the session
A viewer per message keeps each answer independent: a new answer never re-renders the earlier ones, and a viewer has one streaming session at a time. The costly parts are shared between viewers: the syntax highlighter (one per pair of TextMate themes, however many viewers use it), the image cache and the cache of typeset formulas.
Set the pipeline and extensions once with MarkdownViewerDefaults rather than on each viewer; see Settings for every viewer.
ChatTheme.axaml makes the document compact, in the style of AI chat applications: small headings, 8px between blocks, code in cards. It leaves the page to the host: the viewer keeps a transparent background and inherits the font, size and text colour of the bubble around it, and its tints are translucent, so they follow the bubble's colour. See Themes and presets.
Append can be called from any thread, for every token: the viewer renders what arrived at most once per interval (32 ms by default), and each update only renders the end of the document again. Until the text ends, the syntax left open at its end is closed for display, so **bold renders bold rather than with two asterisks, and links, images, diagrams and formula blocks that may still change wait until the text has moved past them.
viewer.AutoScrollToEnd = true; // follow the end; scrolling up pauses it
IsStreaming is true while a session is open, for a "typing" indicator. Setting Markdown ends the session, which is how to drop an answer that is no longer wanted. See Streaming for completing a session from another thread, what happens to the selection, find and folded blocks during a session, and what healing closes.
Reasoning models (DeepSeek R1, Qwen, GLM and most open models) write their reasoning before the answer, between <think> and </think>. UseThinkingBlocks() shows it in a block that folds away instead of as part of the answer:
viewer.UseThinkingBlocks(); // default pipeline + thinking blocks
// or in your own pipeline, with options
viewer.Pipeline = new MarkdownPipelineBuilder()
.UseGitHubExtensions()
.UseThinkingBlocks(new ThinkingBlockOptions { Title = "Reasoning", ExpandWhenDone = true })
.Build();
<think>
The user asks for **two** things:
- a summary
- an example
</think>
Here is the summary…
…so the answer is 4.</think>). A closing tag inside a fenced code block of the reasoning is code.think, thinking, reasoning, reason and thought, in any case.<think>\n\n</think> when thinking is off) renders nothing.ThinkingBlockOptions |
Default | |
|---|---|---|
TagNames |
think, thinking, reasoning, reason, thought |
The tags around the reasoning |
ThinkingTitle |
"Thinking…" |
The header while a session receives the reasoning |
Title |
"Thoughts" |
The header once the reasoning is complete, when no duration is shown |
DurationTitle |
ThinkingBlockOptions.FormatDuration |
Formats the header from the time the reasoning took, when a session saw it start and end ("Thought for 12 seconds", "Thought for 2m 5s"); null to always show Title |
ExpandWhileThinking |
true |
Whether the reasoning shows while it arrives |
ExpandWhenDone |
false |
Whether the reasoning shows once complete, and in a document that is not streamed |
To translate the headers, set ThinkingTitle, Title and DurationTitle. Reasoning that a model API returns apart from the answer (a reasoning or thinking field) can be shown the same way by wrapping it in the tags. See Style classes to restyle the block.
Models write math with \(…\) and \[…\] as often as with dollars. UseMath() (the Math package) reads both, and a formula block still being written shows as its source until it is complete.
Code blocks have a copy button; ShowCodeBlockCopyButtonOnHover shows it only while the pointer is over the block, as many chat applications do. See Code blocks.
CopyAsMarkdownAsync() copies the selection as Markdown, and Copy as Markdown in the context menu does the same; with SelectAll() first, it copies the whole answer. On the desktop, Ctrl+C also puts the selection on the clipboard as HTML, so pasting into a word processor or another chat keeps the formatting. See Selection and copy.