Commit 0a164fc
Add markdown rendering (incl. tables) to native chat bubbles
* Add markdown rendering (incl. tables) to native chat bubbles
Renders block-level Markdown — headings, lists (incl. GFM task lists), tables, blockquotes, fenced/inline code, thematic breaks, strikethrough, underline — inside OpenClaw native chat bubbles. The previous inline-only sanitizer remains the fast path for plain-prose bubbles; bubbles containing block markdown route through a new md4c-based pipeline.
Pipeline:
1. Vendored md4c SAX parser at src/OpenClaw.Shared/Markdown/Md4c/* (copied verbatim from microsoft-ui-reactor @01bb3fbc; see VENDORED.md).
2. ChatMarkdownAstBuilder translates md4c callbacks into a pure-data ChatMarkdownDocument AST.
3. ChatMarkdownRenderer maps the AST to a FunctionalUI Element tree.
4. OpenClawChatTimeline.SafeMarkdownText gates on ContainsBlockMarkdown and falls back to plain TextBlock if rendering fails.
Security posture (preserved end-to-end):
* Links flattened to inert `display (href)` text — never a clickable Hyperlink.
* Images flattened to `[Image: alt]` — no BitmapImage / no remote fetch.
* Raw HTML suppressed via md4c NoHtml flag; residual MdRawTextBlock rendered as inert monospace.
* Hard 256 KB char input cap; oversized payloads truncate to a single inert paragraph.
* Renderer constructs no Hyperlink / BitmapImage / click or pointer handler.
Robustness:
* Builder Build() fully resets state per call so reuse can't bleed style/link/image/code/table state.
* Nested links and images-in-links handled via Stack<(StringBuilder, string?)> / Stack<StringBuilder> so outer span buffers survive inner spans.
* TextCallback handles SoftBr/Br/NullChar before link/image depth capture so line-breaks and U+FFFD aren't lost inside link display / image alt.
* EmitInlineText routes flattened text into outer link/image buffers when the LeaveSpan emit still sits inside an outer span.
* Soft line breaks render as a space Run; hard breaks render as a LineBreak (preserves CommonMark semantics).
* ContainsBlockMarkdown is a cheap O(n) heuristic — handles up to 3 leading spaces (CommonMark), requires whitespace/EOL after #, detects 3+ `-/*/_` thematic breaks, and fenced code with leading indent.
* Bounded 64-entry LRU AST cache in ChatMarkdownRenderer.Render(string) keyed by source so per-tick FunctionalUI re-renders don't reparse stable bubbles. Element trees are always rebuilt (WinUI parent-slot rule).
Tests:
* 18 md4c parser smoke tests.
* 24 AST builder tests covering tables, lists, headings, link/image flattening, raw-HTML suppression, the 256 KB cap.
Validation: ./build.ps1, Shared 2000 passed/29 skipped, Tray 843 passed.
Reviewed via two passes of the Hanselman dual-model (claude-opus-4.6 + gpt-5.2-codex) adversarial review — 12 findings addressed in pass 1, pass 2 clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix tight-list rendering: surface inlines as implicit paragraphs
md4c does NOT emit explicit P open/close events for the raw inline
text inside tight list items (the CommonMark-compliant behavior).
The AST builder was assuming a P would always wrap text inside Li,
so it never drained _inlines at LeaveBlock(Li). The result in real
chat traffic was:
* every bullet rendered empty (Li.Children was empty)
* the accumulated tight-list text bled into the next real
paragraph in the bubble, producing a megaparagraph of all the
list content jammed together.
Two fixes in ChatMarkdownAstBuilder:
1. LeaveBlock(Li): drain any pending _inlines into an implicit
MdParagraph child before constructing the MdListItem, so the
simple tight-list case renders content under the bullet.
2. EnterBlock(*): if the current parent is Li or Quote and we have
pending inlines, flush them into an implicit MdParagraph FIRST.
This handles the nested-block-inside-tight-Li case (e.g. a
nested Ul) where the outer Li's text would otherwise bleed into
the first nested item.
Adds two regression tests in ChatMarkdownAstBuilderTests:
* TightList_LiContentSurfacesAsImplicitParagraph - asserts each
bullet has a paragraph child with the expected inlines, and
that the trailing paragraph contains ONLY its own text.
* TightList_NestedItemsAlsoSurfaceContent - asserts the outer Li
contains both a paragraph and the nested MdList, and that the
nested items also have paragraph content.
Root cause was not caught by either Hanselman review pass because
the existing list tests only counted items and the loose-list path
(blank lines between items) does get explicit P from md4c.
Validation:
build.ps1: green
Shared tests: 2002 passed / 29 skipped
Tray tests: 843 passed
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Decode HTML entities in markdown chat rendering
md4c emits MarkdownTextType.Entity events for syntactically-valid
HTML entity references (e.g. &, A, ©). The AST builder
previously fell through to the default case and dropped them,
producing rendering like "AT&amp;T" instead of "AT&T".
Add an Entity case that decodes the token via a new DecodeEntity
helper and routes it through the existing image-alt / link-display
/ inline-text paths, matching how Normal text is handled.
DecodeEntity covers:
- Decimal numeric refs (&#NN;)
- Hex numeric refs (&#xHH; / &#XHH;, either case)
- Named entities via the existing internal Md4cEntity.EntityLookup
table (including two-codepoint sequences like fj -> "fj")
- CommonMark folding: NUL, surrogates, and codepoints above
U+10FFFF collapse to U+FFFD; the in-loop > 0x10FFFF guard
prevents int overflow on long hex inputs
- Codepoint0 == 0 sentinel (md4c's "no replacement") returns the
raw token verbatim
- Unknown named entities pass through verbatim
- SafeConvertFromUtf32 defensively rejects out-of-range values so
a corrupted entity table can never throw
Add 13 regression tests covering: ampersand, copyright, common
named entities (theory with 8 cases), decimal and hex numeric refs,
supplementary-plane codepoints (surrogate pair), NUL, surrogate
folding, unknown entities, entity inside a strong span, max-valid
codepoint boundary, above-max replacement, and two-codepoint
named entity decoding.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Render visible 1px gridlines in markdown tables
Tables in chat bubbles previously had no borders, making columns
visually ambiguous. The HTML dashboard renders tables with cell
borders; bring the native chat in line.
Each cell paints a 1px top stroke (when rowIndex > 0) and a 1px
left stroke (when colIndex > 0); an outer Border on the grid closes
the bottom and right edges. The result is a uniform 1px grid with
no double-thickness lines at internal cell boundaries.
Color is #40808080 (semi-transparent gray) so it reads well in both
light and dark themes without depending on a theme resource. The
header row gets a subtle Theme.CardBackground tint and slightly
larger cell padding.
A single SolidColorBrush is cached lazily in a static property
(TableGridBrush) and reused by every cell border and the outer
wrapper, avoiding per-cell brush allocation on each table render.
WinUI 3 brushes are shareable across elements; the renderer runs
on the dispatcher thread so the lazy-init is single-threaded.
ParseHex parses the hex constant into a Windows.UI.Color and falls
back to Microsoft.UI.Colors.Transparent on malformed input.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Reserve real layout spacing after markdown list markers
ClawSweeper flagged that list bullets and numbers rendered flush
against their content. The previous implementation packed two
trailing spaces into the marker TextBlock and used a zero-spacing
HStack, but WinUI's text layout collapses trailing whitespace
inside a TextBlock so no actual gap appeared on screen.
Strip the trailing whitespace from the marker strings and give the
marker TextBlock an explicit MinWidth so the content column starts
at a stable x-coordinate. Bullets use 10 px (tight, browser-like);
ordered markers use 18 px so "10." still leaves a small gap before
the content and single-digit numbers stay visually aligned with
multi-digit ones in the same list.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Regis Brid <rbrid@github.com>1 parent 4133ae1 commit 0a164fc
14 files changed
Lines changed: 11289 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
0 commit comments