Message Markdown

Streamdown renderer tuned for streaming answers: GFM, Shiki code, Mermaid diagrams, and KaTeX math, with incomplete markdown parsed safely.

Open in

Retry budget

The client retries with exponential backoff, so the wait before attempt nn is tn=t0⋅2 n−1t_n = t_0 \cdot 2^{\,n-1}.

The policy lives in , and is the only caller — both render as file chips, while BASE_MS stays plain code.

AttemptDelay
1250ms
2500ms
31s
ts
const delay = (attempt: number) => BASE_MS * 2 ** (attempt - 1)
  • 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-markdown

Also 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_MS to shorten the first wait while you are debugging.

Cached tokens still count against the context window.

Raising maxDuration above 60 breaks every production deploy.

git checkout -- discards the file's uncommitted changes.

An ordinary quote, with [!NOTE] aside on 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.

  1. Read the RFC first.
  2. Then change BASE_MS and re-run npm test.
AttemptDelay
1250ms
2500ms
ts
const delay = (attempt: number) => BASE_MS * 2 ** (attempt - 1)

What the clipboard got

Select part of the answer above and copy it.

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.

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 O(nlog⁡n)O(n \log n), and the running total is

∑i=1ni=n(n+1)2\sum_{i=1}^{n} i = \frac{n(n+1)}{2}

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:

Latency band

A remote image works the same way — ![alt](https://…) — 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.

Registry build

The build resolves every path in registry.json before writing anything, so a stale entry fails loudly instead of shipping a broken item.

Run it after any component or registry edit.

  1. Read registry.json
  2. Inline each file
  3. Write public/r/*.json

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:col or #L42 suffix 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