Add markdown rendering (incl. tables) to native chat bubbles#546
Conversation
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>
|
Codex review: found issues before merge. Reviewed May 26, 2026, 7:11 PM ET / 23:11 UTC. Summary Reproducibility: not applicable. this is a feature PR rather than a reported current-main bug. The after-change screenshots demonstrate the intended native chat rendering path. Review metrics: 3 noteworthy metrics.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge Security Review findings
Review detailsBest possible solution: Keep the Markdown renderer direction, but require a public immutable upstream source plus license/third-party notice for the vendored parser before merge. Do we have a high-confidence way to reproduce the issue? Not applicable; this is a feature PR rather than a reported current-main bug. The after-change screenshots demonstrate the intended native chat rendering path. Is this the best way to solve the issue? No; the feature direction is plausible, but this is not the best mergeable form until the vendored parser source, exact upstream commit, and license/notice are verifiable. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model gpt-5.5, reasoning high; reviewed against 4133ae1c7065. Label changesLabel changes:
Label justifications:
Evidence reviewedSecurity concerns:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
|
ClawSweeper PR egg 🔥 Warming up: real-behavior proof passed; findings, security review, or rank-up moves are still in progress. Hatch commandComment Hatchability rules:
What is this egg doing here?
|
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>
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>
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>
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>








Adds block-level Markdown — headings, lists (incl. GFM task lists), tables, blockquotes, fenced/inline code, thematic breaks, strikethrough, underline — to OpenClaw native chat bubbles. The existing inline-only sanitizer remains the fast path for plain-prose bubbles; bubbles containing block markdown route through a new md4c-based pipeline.
Pipeline
Elementtree.ContainsBlockMarkdownand falls back to a plainTextBlockif rendering fails.Security posture (preserved end-to-end)
display (href)text — never a clickableHyperlink.[Image: alt]— noBitmapImage/ no remote fetch.NoHtmlflag; residualMdRawTextBlockrendered as inert monospace.Hyperlink/BitmapImage/ click or pointer handler.Robustness
Build()fully resets state per call so reuse can't bleed style/link/image/code/table state.TextCallbackhandlesSoftBr/Br/NullCharbefore link/image depth capture so line breaks and U+FFFD aren't lost inside link display / image alt.EmitInlineTextroutes flattened text into outer link/image buffers when theLeaveSpanemit still sits inside an outer span.Run; hard breaks render as aLineBreak(preserves CommonMark semantics).ContainsBlockMarkdownis 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.ChatMarkdownRenderer.Render(string)keyed by source so per-tick FunctionalUI re-renders don't reparse stable bubbles.Elementtrees are always rebuilt (WinUI parent-slot rule).Tests
Validation
./build.ps1✅Review
Reviewed via two passes of the Hanselman dual-model adversarial review (claude-opus-4.6 + gpt-5.2-codex):