Message Markdown
Streamdown renderer tuned for streaming answers: GFM, Shiki code, Mermaid diagrams, and KaTeX math, with incomplete markdown parsed safely.
Retry budget
The client retries with exponential backoff, so the wait before attempt is .
The policy lives in , and is
the only caller — both render as file chips, while BASE_MS stays plain code.
- GFM tables, task lists, and footnotes
- Shiki-highlighted code with a copy button
- KaTeX for inline and block math
Installation
npx shadcn@latest add miskibin/chat-components/message-markdownAlso installs file-icon, change-summary.
Usage
import { MessageMarkdown } from "@/components/ui/message-markdown"
export function Answer({ text }: { text: string }) {
return <MessageMarkdown isAnimating>{text}</MessageMarkdown>
}Examples
File references become chips
Inline code that reads as a file path renders as a chip with its FileIcon — `lib/store.ts`, `app/page.tsx:42`, `package.json`, `Dockerfile`. Detection is deliberately conservative: a recognized extension or a well-known extension-less filename qualifies on its own, a slashed string needs three segments (so `and/or` stays code), and URLs, anything with whitespace, and library names like `Next.js` never match. Fenced blocks, mermaid and math are untouched — Streamdown routes them to their own renderers. Everything else renders as ordinary inline code.
Chips, because each one names a file:
, , , ,
, , ,
and — the :42 is what makes that
last one a Perl file rather than a Polish domain.
Plain code, because none of them do: example.com, main.pl,
/chat/settings, and/or, Next.js, Array.prototype.at.
A link works the same way: opens the file, while the docs stays a link.
GitHub alerts
A blockquote whose first line is [!NOTE] — or TIP, IMPORTANT, WARNING, CAUTION — renders as a titled callout. They are GitHub’s own extension rather than GFM, so a small remark plugin lifts the marker onto the quote as data-alert and message-markdown.css styles it. GitHub’s own rule applies: the marker has to be alone on its line, so > [!NOTE] aside stays an ordinary quote.
The retry budget is per request, not per session.
Set
RETRY_BASE_MSto shorten the first wait while you are debugging.
Cached tokens still count against the context window.
Raising
maxDurationabove 60 breaks every production deploy.
git checkout --discards the file's uncommitted changes.
An ordinary quote, with
[!NOTE] asideon one line — which GitHub does not read as an alert either.
Copy keeps the markdown
Highlighting part of an answer and copying it writes markdown, not the flattened text a browser hands over: headings, emphasis, links, lists, tables and fences all survive, a file chip copies as the code span it renders, and a selection that never leaves a code block copies as plain code rather than coming back fenced. Turn it off with copyAsMarkdown={false}.
Backoff
The wait doubles per attempt, and the policy lives in lib/retry/backoff.ts.
- Read the RFC first.
- Then change BASE_MS and re-run
npm test.
What the clipboard got
Select part of the answer above and copy it.
Relative links are file references
A relative link names a file in the workspace, not a page on the site the transcript is rendered on, so [the hook](app/use-turn.ts) renders as the same data-slot="message-file-ref" chip an inline-code path does, and reaches the same onFileClick. A #L88 or :88 suffix arrives as the line. Otherwise the hardening pass, which has no origin to resolve a relative href against, would render [README](README.md) as a grey [blocked] marker and quietly turn ./docs/setup.md into a link off the page. Absolute http(s) and mailto: links are hardened exactly as before; a relative href that names no path (?tab=logs) degrades to the text it wrapped.
Retries back off exponentially. The policy lives in , writes it up, and is the chat route — every one of those is a path in the workspace, so it opens where the answer meant it to. The algorithm itself is plain exponential backoff, and that one is a real link.
An external link opens on the first click
Streamdown’s linkSafety interstitial is off, so an absolute link is a real <a target="_blank"> rather than a button that raises an “Open external link?” modal first. The modal is written for a chat with an untrusted model on a page of other people’s content; in an agent transcript it is a second click between the reader and the docs their own agent just cited — and it costs the anchor, so middle-click, Copy link address and a host that wants to route the click itself all stop working. With a real anchor back, a desktop shell (where target="_blank" has no window to open into) can intercept the click and hand the URL to the system browser.
Retries back off exponentially. The policy lives in , writes it up, and is the chat route — every one of those is a path in the workspace, so it opens where the answer meant it to. The algorithm itself is plain exponential backoff, and that one is a real link.
Mermaid and math
Both plugins are wired up already: fence a diagram as mermaid and write math with $…$ or $$…$$. The Mermaid theme follows the resolved next-themes value, so a diagram flips with the page.
A turn either answers straight away or takes a detour through a tool:
Sorting the candidates costs , and the running total is
Inline images
Images render from http, https and data: sources, so a screenshot or chart an agent hands back as base64 shows up in the answer instead of vanishing. The hardening pass still narrows data: to data:image/*, and message-markdown.css keeps every image inside the message column.
Coverage came out flat, so here is the band it moved in:
A remote image works the same way —  — and both stay
inside the message column however wide the original is.
Restyle the prose
The wrapper carries data-slot="message-markdown" and everything inside it is ordinary HTML, so element selectors from an ancestor retune the measure, the headings, and the quotes.
Notes
API reference
MessageMarkdown
children*string- The markdown source.
isAnimatingboolean = false- True while text is still streaming; unterminated emphasis, links, and fences stop flickering.
patternHandlersMarkdownPatternHandler[] = []- Inline replacements applied to paragraph and list-item text.
onFileClick(path: string, line?: number) => void- Makes every detected file chip a button — an inline-code path and a relative markdown link alike. The path arrives with any
:line:color#L42suffix stripped, and the line beside it. Keep the handler stable — it is read through context so the inline renderer stays memoized while text streams. fileActionsFileActionItem[]- Right-click menu on the file chips and on the images the answer renders (a
data:image gets none — it is not a file). Read through the same context; keep it stable. copyAsMarkdownboolean = true- Copying a selection out of the answer writes markdown to the clipboard instead of the flattened text the browser would put there, with a sanitized copy of the rendered HTML beside it for rich-paste targets. Set it false to hand the clipboard back to the browser.
Data slots
Every part below is addressable from a parent, so you can restyle internals without forking the component.
message-markdownmessage-file-refmessage-markdown-imagemessage-markdown-block-fallbackfile-context-menufile-icon