Windows chat markdown: render tables, and stop destroying links inside bold
Windows renders chat markdown with a 173-line file that splits the text on one alternation regex. macOS uses 1,635 lines (OmiMarkdown.swift). The gap is not only missing features: two of the differences are wrong output.
What the shipped renderer does today
Each of these was run against the current build and the result recorded before anything was changed.
| Input | Renders as |
|---|---|
| a four-line GFM table | one paragraph of literal | a | b | |
> quoted line | literal > quoted line |
- a / - b / - c | one FLAT list; the nesting is discarded |
- [ ] todo | literal [ ] todo |
~~gone~~ | literal tildes |
Before / --- / After | one paragraph with the dashes inside it |
# and ###### | identical bold paragraphs |
**see [docs](https://x.com) now** | the link is destroyed: bold text containing the raw markdown characters |
a \*not italic\* b | mangled: the backslashes show AND it italicises |
The last two are the ones that matter most. They are not missing cases, they are what a flat split does: it cannot nest, so anything inside a matched pair is treated as literal text, and escapes are invisible to it.
Tables are the reason this is worth doing at all. The model emits them constantly, because a table is the natural shape for the comparisons users actually ask for, and every one of them has been arriving as unreadable pipe soup.
Shape
lib/markdown/inline.ts emphasis, code spans, links, escapes -> tree
lib/markdown/table.ts GFM tables -> table
lib/markdown/blocks.ts the block grammar -> blocks
components/Markdown.tsx blocks + inline -> elements
Parsing is separated from rendering, so the entire grammar is a pure function of a string and is tested without a DOM. The component only turns the tree into elements.
Rules worth knowing
Scanner order is load-bearing and is macOS': fenced code first, then tables, then everything else. Code first is what keeps a --- or a | a | b | inside a code sample literal instead of turning it into a rule or a table.
Code spans outrank everything inline. That is what makes `**not bold**` work, and it means a delimiter inside a code span can no longer close an emphasis run that opened outside it.
A single asterisk steps over a double run when looking for its closer. Without that, *italic with **bold** inside* closed the italic on the opening ** and produced garbage for the rest of the line.
Underscores may not open or close inside a word, so my_var_name stays an identifier. Asterisks still work intraword, which is what CommonMark says.
Link destinations are read with balanced parentheses. The first ) is not the end: real URLs contain parens, and so does the very href this app refuses, so [x](javascript:alert(1)) has to be read whole before it can be judged at all.
Table cells split on pipes that are neither escaped nor inside a code span, ported from macOS rather than guessed, so a cell can hold \| or `a | b`. And a table is only a table if the separator parses as alignments for exactly the header's columns, which is what stops a paragraph containing a pipe being read as one.
Unclosed markers stay literal. A reply is streamed, so a half-typed ** is the normal state, not an error. One test renders every prefix of a representative document, because the renderer runs on every token and any prefix that looped would hang the app.
Security
Chat is untrusted input in the ordinary case here, not only under attack: the prompt includes OCR of whatever is on the user's screen, so a reply can be steered by indirect prompt injection into emitting any markup it likes. Three rules follow, and they live in the parser with their own tests rather than inside a component.
The href allowlist is carried over and extended. http, https and mailto become links; anything else renders its label as plain text with no anchor at all. New: an href containing a control character is refused outright, because URL parsers strip those before reading the scheme. That blocks java\tscript:alert(1), and it also blocks https://omi.me\t@evil.example, where the scheme really is https so the allowlist alone passes it while the host actually visited is evil.example.
Images are reduced to alt text and nothing is fetched. Deliberate, not unimplemented. Rendering  would make an injected address a tracking pixel that reports the user's IP and that the chat was opened, on load, with nothing clicked.
No raw HTML, ever. React elements only, which the tests assert directly: raw tags render as text, HTML inside a code block renders as text, and a markdown image produces no img element and no request.
Verification
Run in desktop/windows:
npx vitest run- 5,340 passed, 18 skipped, 546 files. 105 of those are this change; the branch point was 5,235. The three pre-existingMarkdowntests pass unmodified.npx tsc --noEmitontsconfig.web.jsonandtsconfig.node.json- clean.npx eslint .- 0 errors.npx electron-vite build- clean.check_brand_ui.py,check_lifecycle_headers.py- pass.
Mutation audit
49 regressions seeded, 48 caught.
It found four real gaps, all now closed, and three of them are the same shape: a guard that looked covered because every input tested against it also failed a different guard.
The control-character rule in the href check could be deleted with no test failing, because every case tested also failed the scheme allowlist. The case that gives it its own weight is https://omi.me\t@evil.example, above.
The href check on the autolink path was the same story until tested with a URL carrying a control character, which the autolink pattern admits because it excludes only whitespace.
The intraword-underscore rule has two halves and only the closing one was covered. my_var_ end separates them: the closer there is legal, so only the opening rule stops var italicising.
The CRLF test asserted block kinds, which pass either way because a lone carriage return still trims to empty and still splits the paragraphs. The carriage returns were surviving inside the text, where they reach the DOM.
One escape is left and is documented where it sits: a table ends at a blank line, which the cell-count rule below it already subsumes, since a blank line splits into a single empty cell. It stays because it states the rule a reader is looking for, which the cell count only implies, and because macOS carries the same pair.
Deliberate divergences from macOS
| Divergence | Why |
|---|---|
| An unclosed fence keeps the rest of the reply as code; macOS keeps it as text | Mid-stream the closing fence has not arrived yet. Watching the block form reads better than watching raw markdown reflow the moment it closes. This is also the shipped Windows behaviour, so it is a divergence being kept rather than introduced. |
| No citation tokens | macOS' inline citation masking belongs to its chat citation feature, which Windows does not have. |
| No inline-code copy button | macOS makes every inline code span its own copy target. Windows keeps the copy button on fenced blocks only, which is the existing behaviour and the one the current tests pin. |
| Images render as alt text | See above; this is a security position, not a gap. |