"use client";
// MarkdownBlock — tiny zero-dep Markdown renderer. Handles the subset
// the refiner emits: h1/h2/h3 headings, - / * bullets, 1. numbered
// lists, `**bold**`, `` `code` ``, blank-line-separated paragraphs.
// Not a general-purpose renderer — deliberately small to avoid a
// react-markdown dep for one canvas surface.
import React from "react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
type Block =
| { kind: "h1" | "h2" | "h3"; text: string; id?: string }
| { kind: "p"; text: string }
| { kind: "ul"; items: string[] }
| { kind: "ol"; items: string[] }
| { kind: "code"; lang: string; text: string };
/** Stable slug for a heading, so the reader's outline can scroll to it. */
export function headingId(text: string, ordinal: number): string {
const slug = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
return `h-${ordinal}-${slug || "section"}`;
}
/** The headings of a document, for an outline rail. */
export function outlineOf(
md: string,
): Array<{ id: string; text: string; level: 1 | 2 | 3 }> {
return parse(md).flatMap((b, idx) =>
b.kind === "h1" || b.kind === "h2" || b.kind === "h3"
? [
{
id: b.id ?? headingId(b.text, idx),
text: b.text,
level: Number(b.kind.slice(1)) as 1 | 2 | 3,
},
]
: [],
);
}
function parse(md: string): Block[] {
const lines = md.replace(/\r\n/g, "\n").split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i++;
continue;
}
// Fenced code block. Agent output is full of ```rust / ```toml
// blocks; without this they render as mangled paragraphs.
const fence = /^```([A-Za-z0-9_+-]*)\s*$/.exec(trimmed);
if (fence) {
const lang = fence[1] ?? "";
const body: string[] = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
body.push(lines[i]);
i++;
}
i++; // consume the closing fence (or run off the end on an unclosed block)
blocks.push({ kind: "code", lang, text: body.join("\n") });
continue;
}
// Headings
const h = /^(#{1,6})\s+(.*)$/.exec(trimmed);
if (h) {
// h4-h6 are rare in agent output; render them as h3 rather than
// dropping the text into a paragraph.
const level = Math.min(h[1].length, 3) as 1 | 2 | 3;
const text = h[2];
blocks.push({
kind: (`h${level}` as "h1" | "h2" | "h3"),
text,
id: headingId(text, blocks.length),
});
i++;
continue;
}
// Bullet list
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
i++;
}
blocks.push({ kind: "ul", items });
continue;
}
// Numbered list
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
i++;
}
blocks.push({ kind: "ol", items });
continue;
}
// Paragraph — greedily accumulate until blank line or block boundary
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() &&
!/^(#{1,6})\s+/.test(lines[i].trim()) &&
!/^```/.test(lines[i].trim()) &&
!/^[-*]\s+/.test(lines[i].trim()) &&
!/^\d+\.\s+/.test(lines[i].trim())
) {
paraLines.push(lines[i].trim());
i++;
}
if (paraLines.length) blocks.push({ kind: "p", text: paraLines.join(" ") });
}
return blocks;
}
// Inline: **bold**, `code`. Simple sequential scan.
function renderInline(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
const tok = m[0];
if (tok.startsWith("**")) {
out.push(
{tok.slice(2, -2)}
,
);
} else {
out.push(
{tok.slice(1, -1)}
,
);
}
last = m.index + tok.length;
}
if (last < text.length) out.push(text.slice(last));
return out;
}
export function MarkdownBlock({ source }: { source: string }) {
const blocks = React.useMemo(() => parse(source), [source]);
return (
{b.lang && (
{b.lang}
)}
{b.text}
);
if (b.kind === "p")
return (
{renderInline(b.text)}
); if (b.kind === "ul") return (