Render Mermaid diagrams and worktree images in agent Markdown

This commit is contained in:
Ian Gulliver
2026-10-15 07:27:17 -07:00
parent 1f44bc6d91
commit 4ca6f88861
26 changed files with 5613 additions and 227 deletions
+60 -30
View File
@@ -1,22 +1,23 @@
import { h } from "./dom.js";
import { diagram } from "./mermaid.js";
const choiceMarker = /^(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$/;
const detailsOpen = /^\s*<details>\s*(?:<summary>(.*?)<\/summary>)?\s*$/i;
const summaryLine = /^\s*<summary>(.*?)<\/summary>\s*$/i;
const detailsClose = /^\s*<\/details>\s*$/i;
const fence = /^\s*(```|~~~)/;
const fence = /^\s*(```|~~~)\s*(\S*)/;
const heading = /^(#{1,6})\s+(.*)$/;
const bullet = /^(\s*)[-*+]\s+(.*)$/;
const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/;
const quote = /^\s*>\s?(.*)$/;
const rule = /^\s*([-*_])(\s*\1){2,}\s*$/;
const tableSep = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
export function markdown(src, className = "md", choice) {
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n"), choice));
export function markdown(src, className = "md", opts = {}) {
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n"), opts));
}
export function inlineMarkdown(src) {
return h("span", { class: "md-inline" }, ...inline(src));
export function inlineMarkdown(src, opts = {}) {
return h("span", { class: "md-inline" }, ...inline(src, opts));
}
function blocks(lines, choice) {
function blocks(lines, opts) {
const out = [];
let i = 0;
while (i < lines.length) {
@@ -35,7 +36,7 @@ function blocks(lines, choice) {
i++;
}
i++;
out.push(h("pre", { class: "md-code" }, h("code", {}, body.join("\n"))));
out.push(f[2].toLowerCase() === "mermaid" ? diagram(body.join("\n")) : h("pre", { class: "md-code" }, h("code", {}, body.join("\n"))));
continue;
}
if (detailsClose.test(line)) {
@@ -63,13 +64,13 @@ function blocks(lines, choice) {
}
body.push(l);
}
out.push(h("details", { class: "md-details" }, h("summary", {}, ...inline(title?.trim() || "Details")), h("div", { class: "md-details-body" }, ...blocks(body, choice))));
out.push(h("details", { class: "md-details" }, h("summary", {}, ...inline(title?.trim() || "Details", opts)), h("div", { class: "md-details-body" }, ...blocks(body, opts))));
continue;
}
const hd = heading.exec(line);
if (hd) {
const level = Math.min(hd[1].length + 2, 6);
out.push(h(`h${level}`, {}, ...inline(hd[2])));
out.push(h(`h${level}`, {}, ...inline(hd[2], opts)));
i++;
continue;
}
@@ -84,11 +85,11 @@ function blocks(lines, choice) {
body.push(quote.exec(lines[i])[1]);
i++;
}
out.push(h("blockquote", {}, ...blocks(body)));
out.push(h("blockquote", {}, ...blocks(body, { taskId: opts.taskId })));
continue;
}
if (bullet.test(line) || ordered.test(line)) {
const [list, next] = parseList(lines, i, choice);
const [list, next] = parseList(lines, i, opts);
out.push(list);
i = next;
continue;
@@ -100,7 +101,7 @@ function blocks(lines, choice) {
rows.push(cells(lines[i]));
i++;
}
out.push(table(rows));
out.push(table(rows, opts));
continue;
}
const para = [];
@@ -108,7 +109,7 @@ function blocks(lines, choice) {
para.push(lines[i].trim());
i++;
}
out.push(h("p", {}, ...joinLines(para)));
out.push(h("p", {}, ...joinLines(para, opts)));
}
return out;
}
@@ -124,20 +125,20 @@ function startsBlock(lines, i) {
ordered.test(line) ||
(line.includes("|") && i + 1 < lines.length && tableSep.test(lines[i + 1])));
}
function joinLines(lines) {
function joinLines(lines, opts) {
const out = [];
lines.forEach((l, n) => {
if (n > 0) {
out.push(h("br"));
}
out.push(...inline(l));
out.push(...inline(l, opts));
});
return out;
}
function indentOf(line) {
return line.length - line.trimStart().length;
}
function parseList(lines, start, choice) {
function parseList(lines, start, opts) {
const first = lines[start];
const isOrdered = !bullet.test(first);
const base = indentOf(first);
@@ -175,17 +176,17 @@ function parseList(lines, start, choice) {
body.push(indentOf(next) > base ? next.slice(Math.min(indentOf(next), base + 2)) : next.trim());
i++;
}
list.append(item(body, isOrdered ? undefined : choice));
list.append(item(body, isOrdered ? { taskId: opts.taskId } : opts));
while (i < lines.length && lines[i].trim() === "" && lines[i + 1] !== undefined && indentOf(lines[i + 1]) === base && (isOrdered ? ordered : bullet).test(lines[i + 1])) {
i++;
}
}
return [list, i];
}
function item(body, choice) {
function item(body, opts) {
const li = h("li");
const m = choice && choiceMarker.exec(body[0]);
const picker = m ? choice(m[2]) : null;
const m = opts.choice && choiceMarker.exec(body[0]);
const picker = m ? opts.choice(m[2]) : null;
if (picker) {
li.className = "choice";
li.append(picker);
@@ -193,7 +194,7 @@ function item(body, choice) {
const first = picker ? [] : [body[0]];
const rest = body.slice(1);
if (rest.every((l) => !startsBlockLine(l)) && !rest.includes("")) {
li.append(...joinLines([...first, ...rest].map((l) => l.trim())));
li.append(...joinLines([...first, ...rest].map((l) => l.trim()), opts));
return li;
}
const head = first;
@@ -202,7 +203,7 @@ function item(body, choice) {
head.push(body[k].trim());
k++;
}
li.append(...joinLines(head), ...blocks(body.slice(k), choice));
li.append(...joinLines(head, opts), ...blocks(body.slice(k), opts));
return li;
}
function startsBlockLine(line) {
@@ -218,12 +219,12 @@ function cells(line) {
}
return s.split("|").map((c) => c.trim());
}
function table(rows) {
function table(rows, opts) {
const [head, ...body] = rows;
return h("div", { class: "md-table" }, h("table", {}, h("thead", {}, h("tr", {}, ...head.map((c) => h("th", {}, ...inline(c))))), h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => h("td", {}, ...inline(c))))))));
return h("div", { class: "md-table" }, h("table", {}, h("thead", {}, h("tr", {}, ...head.map((c) => h("th", {}, ...inline(c, opts))))), h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => h("td", {}, ...inline(c, opts))))))));
}
const inlineToken = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
function inline(src) {
const inlineToken = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
function inline(src, opts) {
const out = [];
let rest = src;
while (rest.length > 0) {
@@ -235,18 +236,21 @@ function inline(src) {
if (m.index > 0) {
out.push(document.createTextNode(rest.slice(0, m.index)));
}
const [whole, code, strong1, strong2, em1, em2, linkText, linkHref, bare] = m;
const [whole, code, strong1, strong2, em1, em2, imgAlt, imgAngled, imgSrc, linkText, linkHref, bare] = m;
if (code !== undefined) {
out.push(h("code", {}, code));
}
else if (strong1 !== undefined || strong2 !== undefined) {
out.push(h("strong", {}, ...inline(strong1 ?? strong2)));
out.push(h("strong", {}, ...inline(strong1 ?? strong2, opts)));
}
else if (em1 !== undefined || em2 !== undefined) {
out.push(h("em", {}, ...inline(em1 ?? em2)));
out.push(h("em", {}, ...inline(em1 ?? em2, opts)));
}
else if (imgAlt !== undefined) {
out.push(image((imgAngled ?? imgSrc).trim(), imgAlt, opts.taskId));
}
else if (linkText !== undefined) {
out.push(link(linkHref, inline(linkText)));
out.push(link(linkHref, inline(linkText, opts)));
}
else if (bare !== undefined) {
out.push(link(bare, [document.createTextNode(bare)]));
@@ -264,4 +268,30 @@ function link(href, children) {
}
return h("span", {}, ...children);
}
function worktreeImageURL(path, taskId) {
if (/^data:image\/(png|jpeg|gif|webp);/i.test(path)) {
return path;
}
let file = path;
if (/^file:\/\//i.test(file)) {
file = decodeURI(file.slice("file://".length));
}
else if (/^[a-z][a-z0-9+.-]*:/i.test(file)) {
return null;
}
if (!taskId) {
return null;
}
return `/api/tasks/${encodeURIComponent(taskId)}/image?path=${encodeURIComponent(file)}`;
}
export function image(src, alt, taskId) {
const url = worktreeImageURL(src, taskId);
if (!url) {
return link(src, [document.createTextNode(alt || src)]);
}
const img = h("img", { src: url, alt, loading: "lazy" });
const el = h("a", { class: "md-img", href: url, target: "_blank", rel: "noopener" }, img);
img.onerror = () => el.replaceWith(h("span", { class: "md-img-missing" }, `image not available: ${src}`));
return el;
}
//# sourceMappingURL=markdown.js.map