diff --git a/README.md b/README.md
index 4c8149e..f8e2d85 100644
--- a/README.md
+++ b/README.md
@@ -752,6 +752,27 @@ author starts as a draft. An agent has no drafting step — it posts a review it
has already decided on — so `"author":"claude"` is born submitted: an open thread,
with no **Submit review** click standing between it and being read.
+### Comments are markdown
+
+A comment body is markdown, rendered where it is read: fenced code (highlighted
+by the same theme as the diff beside it), `inline code`, bold, italic,
+strikethrough, links, bare URLs, nested and numbered lists with `- [ ]` task
+items, headings, quotes and rules. Soft line breaks are hard breaks, as they are
+in a GitHub comment box — comments are typed in lines, not in paragraphs.
+
+It matters most for the comments you did not type. An agent writes the way it
+writes — a claim, a fenced snippet of the fix, a link — and before this that
+arrived as a wall of asterisks and backticks. Nothing about the stored comment
+changed: the body is still plain text, in `reviews.json`, and the editor still
+edits the text you wrote. Tables and reference links are not rendered; they show
+as the text they were typed as. The renderer is `web/src/lib/markdown.tsx`, five hundred
+lines and no dependency — the pane already owns a syntax
+highlighter, and the shapes a review comment actually uses are a short list.
+
+The rail's cards and a collapsed thread's one-line preview strip the markup
+instead of rendering it: three clamped lines have no room for a code block, and
+raw `**syntax**` reads worse than none.
+
### The two skills
`mise run install-skills` installs them into `~/.claude/skills`, keeping whatever
diff --git a/web/src/components/CommentThread.tsx b/web/src/components/CommentThread.tsx
index 9dd46c1..1adcedd 100644
--- a/web/src/components/CommentThread.tsx
+++ b/web/src/components/CommentThread.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import type { Comment } from '../types';
import { api } from '../api';
+import { markdownToText, renderMarkdown } from '../lib/markdown';
import { Icon } from './Icon';
function initials(author: string): string {
@@ -37,8 +38,10 @@ export function CommentThread({ comments, onChanged }: Props) {
}
// summarize reduces a thread to the single line shown while it is collapsed.
+// The markup comes off first: one clamped line has no room to render it, and
+// raw `**syntax**` reads worse than none.
function summarize(body: string): string {
- const line = body.trim().split('\n')[0];
+ const line = markdownToText(body).split('\n').find((l) => l.trim()) ?? '';
return line.length > 110 ? line.slice(0, 110) + '…' : line;
}
@@ -270,7 +273,7 @@ function BodyEditor({
}}
/>
- ⌘⏎ to save · esc to cancel
+ markdown · ⌘⏎ to save · esc to cancel
@@ -330,7 +333,7 @@ function Bubble({
)}
- {editor ??
{body}
}
+ {editor ??
{renderMarkdown(body)}
}
);
diff --git a/web/src/components/CommentsPanel.tsx b/web/src/components/CommentsPanel.tsx
index a11e727..dbeae3b 100644
--- a/web/src/components/CommentsPanel.tsx
+++ b/web/src/components/CommentsPanel.tsx
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import type { Comment, Status } from '../types';
import { Icon } from './Icon';
+import { markdownToText } from '../lib/markdown';
// Filters are by status, plus `outdated`, which cuts across status: it's about
// whether the diff can still place a comment, not where it is in its lifecycle.
@@ -209,7 +210,11 @@ export function CommentsPanel({
{c.author === 'claude' ? 'Claude' : 'You'}
- {c.body}
+ {/* Three clamped lines in a narrow rail: the markup
+ comes off rather than being rendered into it. */}
+
+ {markdownToText(c.body)}
+
{c.replies.length > 0 && (
{c.replies.length}{' '}
diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx
index 7f972cd..55ccf51 100644
--- a/web/src/components/Composer.tsx
+++ b/web/src/components/Composer.tsx
@@ -41,7 +41,7 @@ export function Composer({ onSubmit, onCancel }: Props) {
}}
/>
- ⌘⏎ to add · esc to cancel
+ markdown · ⌘⏎ to add · esc to cancel
diff --git a/web/src/lib/language.ts b/web/src/lib/language.ts
index 8cf8df7..43aa2f4 100644
--- a/web/src/lib/language.ts
+++ b/web/src/lib/language.ts
@@ -102,6 +102,29 @@ export function languageForFile(path: string): string | null {
return null;
}
+// languageForName resolves the info string on a markdown fence (```ts) to a
+// registered refractor language. Fences and file extensions name languages the
+// same way often enough that EXT_TO_LANG does most of the work; the rest are
+// the spellings people write in a fence but never in a filename.
+const FENCE_ALIASES: Record = {
+ shell: 'bash',
+ sh: 'bash',
+ console: 'bash',
+ 'c++': 'cpp',
+ 'c#': 'csharp',
+ dockerfile: 'docker',
+ make: 'makefile',
+ text: '',
+ plain: '',
+};
+
+export function languageForName(name: string): string | null {
+ const key = name.trim().toLowerCase();
+ if (!key) return null;
+ const lang = FENCE_ALIASES[key] ?? EXT_TO_LANG[key] ?? key;
+ return lang && refractor.registered(lang) ? lang : null;
+}
+
// react-diff-view (v3) expects refractor.highlight() to return an ARRAY of
// nodes (refractor v3 behavior). refractor v4 returns a `root` node instead, so
// we adapt by handing back its children. Pass this to tokenize().
diff --git a/web/src/lib/markdown.tsx b/web/src/lib/markdown.tsx
new file mode 100644
index 0000000..5107a3c
--- /dev/null
+++ b/web/src/lib/markdown.tsx
@@ -0,0 +1,501 @@
+import type { ReactNode } from 'react';
+
+import { languageForName, refractor } from './language';
+
+/* ============================================================================
+ markdown — the subset of GitHub-flavoured markdown a review comment uses
+
+ Comments are stored and edited as plain text; this turns that text into
+ nodes for display. It is deliberately hand-rolled rather than a markdown
+ dependency: the review UI already owns its own syntax highlighting, the
+ bundle is served out of the binary, and the shapes that actually turn up in
+ a review comment are a small, stable list —
+
+ fenced code (highlighted through refractor, same theme as the diff),
+ `inline code`, **bold**, *italic*, ~~strike~~, [links](https://…),
+ bare URLs, bullet/numbered lists (nested, with `- [ ]` task items),
+ # headings, > quotes, --- rules, and paragraphs.
+
+ Tables and reference-style links are not supported; they render as the raw
+ text they were typed as, which is the same thing a reader would have seen
+ before any of this existed. Soft line breaks are hard breaks, as they are in
+ a GitHub comment box — people type comments in lines, not in paragraphs.
+ ========================================================================== */
+
+const FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*(\S*)/;
+const HEADING = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/;
+const HR = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/;
+const QUOTE = /^ {0,3}>[ \t]?(.*)$/;
+const BULLET = /^([ \t]*)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/;
+const TASK = /^\[([ xX])\][ \t]+/;
+const LINK = /^\[((?:[^\][\\]|\\.)*)\]\([ \t]*([^\s<>)]*)>?(?:[ \t]+"[^"]*")?[ \t]*\)/;
+const AUTOLINK = /^<(https?:\/\/[^\s<>]+|mailto:[^\s<>]+)>/;
+const BARE_URL = /^https?:\/\/[^\s<>()[\]"'`]+/;
+const PUNCT = /[\\`*_{}[\]()#+\-.!~>|]/;
+
+// renderMarkdown turns a comment body into nodes. The result is a fragment's
+// worth of children, so the caller keeps ownership of the wrapper — it needs
+// the `md` class on it for the styles to apply.
+export function renderMarkdown(body: string): ReactNode {
+ // Tabs only ever appear as indentation in practice, and every block rule
+ // below measures indentation in spaces.
+ return <>{blocks(body.replace(/\t/g, ' ').split('\n'), 'b')}>;
+}
+
+// ---- blocks ---------------------------------------------------------------
+
+function indentOf(line: string): number {
+ return line.length - line.trimStart().length;
+}
+
+function startsBlock(line: string): boolean {
+ return (
+ FENCE.test(line) ||
+ HEADING.test(line) ||
+ HR.test(line) ||
+ QUOTE.test(line) ||
+ BULLET.test(line)
+ );
+}
+
+function blocks(lines: string[], key: string): ReactNode[] {
+ const out: ReactNode[] = [];
+ let i = 0;
+ let n = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+ const k = `${key}-${n++}`;
+
+ if (!line.trim()) {
+ i++;
+ continue;
+ }
+
+ const fence = FENCE.exec(line);
+ if (fence) {
+ const marker = fence[1];
+ let end = i + 1;
+ while (end < lines.length) {
+ const close = lines[end].trim();
+ if (close.startsWith(marker[0].repeat(marker.length)) && /^(`+|~+)$/.test(close)) break;
+ end++;
+ }
+ out.push(codeBlock(lines.slice(i + 1, end).join('\n'), fence[2], k));
+ // An unterminated fence swallows the rest of the comment, which is what
+ // every markdown renderer does with it.
+ i = end + 1;
+ continue;
+ }
+
+ const heading = HEADING.exec(line);
+ if (heading) {
+ const Tag = `h${heading[1].length}` as 'h1';
+ out.push(
+
+ {inline(heading[2], k)}
+ ,
+ );
+ i++;
+ continue;
+ }
+
+ // Checked before lists: `- - -` is a rule, not three nested bullets.
+ if (HR.test(line)) {
+ out.push();
+ i++;
+ continue;
+ }
+
+ if (QUOTE.test(line)) {
+ const quoted: string[] = [];
+ while (i < lines.length) {
+ const m = QUOTE.exec(lines[i]);
+ if (!m) break;
+ quoted.push(m[1]);
+ i++;
+ }
+ out.push(
+
,
+ );
+ }
+
+ return out;
+}
+
+// list consumes one whole list — every item at the starting indentation, plus
+// each item's continuation lines — and returns it with the index just past it.
+function list(lines: string[], start: number, key: string): [ReactNode, number] {
+ const first = BULLET.exec(lines[start])!;
+ const base = first[1].length;
+ const ordered = /\d/.test(first[2]);
+ // How far an item's continuation lines are indented: past the marker.
+ const pad = base + first[2].length + 1;
+
+ const items: string[][] = [];
+ let i = start;
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (!line.trim()) {
+ // A blank line is inside the list only if the list carries on after it.
+ const next = lines[i + 1];
+ if (
+ next &&
+ next.trim() &&
+ (indentOf(next) > base || (BULLET.test(next) && indentOf(next) >= base))
+ ) {
+ items[items.length - 1]?.push('');
+ i++;
+ continue;
+ }
+ break;
+ }
+
+ const marker = BULLET.exec(line);
+ const indent = indentOf(line);
+
+ // A marker at (about) the base indentation opens the next item; anything
+ // indented past it belongs to the item that is open.
+ if (marker && !HR.test(line) && indent <= base + 1) {
+ items.push([marker[3]]);
+ i++;
+ continue;
+ }
+ if (indent > base && items.length > 0) {
+ items[items.length - 1].push(line.slice(Math.min(indent, pad)));
+ i++;
+ continue;
+ }
+ break;
+ }
+
+ const Tag = (ordered ? 'ol' : 'ul') as 'ol';
+ const node = (
+
+ {items.map((item, idx) => listItem(item, `${key}-${idx}`))}
+
+ );
+ return [node, i];
+}
+
+// listItem renders one list item. Its opening lines stay inline so a tight list
+// keeps its tight spacing; anything after them (a nested list, a fenced block,
+// a second paragraph) goes through the block parser.
+function listItem(lines: string[], key: string): ReactNode {
+ let split = 0;
+ while (split < lines.length && lines[split].trim() && !startsBlock(lines[split])) split++;
+
+ let lead = lines.slice(0, split).join('\n');
+ const task = TASK.exec(lead);
+ if (task) lead = lead.slice(task[0].length);
+
+ return (
+