From be554d0ede720d8335a3d546d200c572057d5ac9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 6 Sep 2026 17:31:15 +0100 Subject: [PATCH] feat: render structured responses as markdown --- web/package-lock.json | 15 +++++- web/package.json | 3 +- web/pnpm-lock.yaml | 10 ++++ web/src/structuredRender.test.ts | 19 ++++++++ web/src/structuredRender.ts | 47 +++++++++++++----- web/src/styles.css | 83 ++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 14 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 473d113..5bb7750 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,8 @@ "@fontsource/jetbrains-mono": "^5.3.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "^5.5.0" + "@xterm/xterm": "^5.5.0", + "marked": "^18.0.11" }, "devDependencies": { "happy-dom": "^20.11.6", @@ -1222,6 +1223,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/web/package.json b/web/package.json index ef2765d..f496bbf 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,8 @@ "@fontsource/jetbrains-mono": "^5.3.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "^5.5.0" + "@xterm/xterm": "^5.5.0", + "marked": "^18.0.11" }, "devDependencies": { "happy-dom": "^20.11.6", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5219b6d..cc86f77 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 + marked: + specifier: ^18.0.11 + version: 18.0.11 devDependencies: happy-dom: specifier: ^20.11.6 @@ -424,6 +427,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} + engines: {node: '>= 20'} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -864,6 +872,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@18.0.11: {} + ms@2.1.3: {} nanoid@3.3.12: {} diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts index 7fce155..b85a388 100644 --- a/web/src/structuredRender.test.ts +++ b/web/src/structuredRender.test.ts @@ -5,6 +5,7 @@ import { buildToolGroup, buildTranscriptRow, renderInlineMarkdown, + renderMarkdown, } from "./structuredRender.js"; import type { TranscriptRow } from "./types.js"; @@ -82,6 +83,24 @@ describe("renderInlineMarkdown", () => { }); }); +describe("renderMarkdown", () => { + it("renders GFM tables as semantic table markup", () => { + const html = renderMarkdown("| Model | State |\n| --- | --- |\n| Opus | ready |"); + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).toContain(""); + }); + + it("removes executable HTML and unsafe URLs", () => { + const html = renderMarkdown( + '[bad](javascript:alert(3))', + ); + expect(html).not.toContain(" { it("shows glyph, name, argument and no meta for a completed non-diff call", () => { const el = buildTranscriptRow( diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index 14a66ba..3513e34 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -4,6 +4,7 @@ // unit-testable without a WebSocket. import { escapeHtml } from "./html.js"; +import { marked, Renderer } from "marked"; import { computeDiffLines, diffStats } from "./structuredDiff.js"; import type { AcpContentBlock, @@ -95,10 +96,7 @@ function contentBlockText(block: AcpContentBlock | undefined): string { } } -/** A tiny, dependency-free inline renderer: `**bold**`, `*italic*`, and - * `` `code` `` become real markup; everything else stays escaped plain - * text. No block-level markdown (lists, headings) — agent prose is - * paragraphs, not documents. */ +/** Small inline renderer retained for compact labels and focused tests. */ export function renderInlineMarkdown(text: string): string { const escaped = escapeHtml(text); return escaped @@ -107,15 +105,37 @@ export function renderInlineMarkdown(text: string): string { .replace(/(?$1"); } +/** Render assistant output as GFM, then sanitize the complete result before it + * enters the DOM. This gives transcripts proper tables, lists, code blocks and + * links without trusting model-authored HTML or URL attributes. */ +export function renderMarkdown(text: string): string { + const renderer = new Renderer(); + renderer.html = ({ text: raw }) => escapeHtml(raw); + renderer.image = ({ text: alt }) => escapeHtml(alt ? `[image: ${alt}]` : "[image]"); + renderer.link = ({ href, title, tokens }) => { + const label = renderer.parser.parseInline(tokens); + const scheme = /^([a-z][a-z\d+.-]*):/i.exec(href.trim())?.[1]?.toLowerCase(); + if (scheme && !["http", "https", "mailto"].includes(scheme)) return label; + const safeTitle = title ? ` title="${escapeHtml(title)}"` : ""; + return `${label}`; + }; + + return marked.parse(text, { async: false, gfm: true, renderer }); +} + function renderProse(container: HTMLElement, text: string): void { - const paragraphs = text.split(/\n{2,}/); - for (const para of paragraphs) { - if (!para) continue; - const p = document.createElement("p"); - p.innerHTML = renderInlineMarkdown(para); - container.appendChild(p); + container.innerHTML = renderMarkdown(text); + + for (const table of Array.from(container.querySelectorAll("table"))) { + const scroller = el("div", "st-markdown-table-wrap"); + table.replaceWith(scroller); + scroller.appendChild(table); + } + + for (const link of container.querySelectorAll("a")) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; } - if (container.children.length === 0) container.textContent = ""; } async function writeClipboard(text: string): Promise { @@ -158,7 +178,10 @@ function makeCopyable(node: HTMLElement, text: string, label: string): void { }, ); }; - node.addEventListener("click", copy); + node.addEventListener("click", (event) => { + if ((event.target as Element | null)?.closest("a")) return; + copy(); + }); node.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); diff --git a/web/src/styles.css b/web/src/styles.css index 28377db..618edb5 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1892,6 +1892,7 @@ input.leaf-rename:focus { align-items: center; gap: var(--sp-2); height: var(--bar-h); + flex: 0 0 var(--bar-h); padding: 0 var(--sp-2); border-bottom: 1px solid var(--border); background: var(--surface); @@ -2388,6 +2389,88 @@ input.leaf-rename:focus { margin-bottom: 0; } +.st-row-agent .st-body > :first-child { + margin-top: 0; +} + +.st-row-agent .st-body > :last-child { + margin-bottom: 0; +} + +.st-row-agent .st-body h1, +.st-row-agent .st-body h2, +.st-row-agent .st-body h3, +.st-row-agent .st-body h4 { + margin: var(--sp-4) 0 var(--sp-2); + color: var(--ink); + font-size: var(--fs-14); + line-height: 1.35; +} + +.st-row-agent .st-body ul, +.st-row-agent .st-body ol { + margin: var(--sp-2) 0; + padding-left: var(--sp-5); +} + +.st-row-agent .st-body li + li { + margin-top: var(--sp-1); +} + +.st-row-agent .st-body pre { + margin: var(--sp-2) 0; + padding: var(--sp-3); + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--r-2); + background: var(--surface-2); +} + +.st-row-agent .st-body pre code { + padding: 0; + border: 0; + background: transparent; +} + +.st-row-agent .st-body a { + color: var(--ink); + text-decoration-color: var(--border-strong); + text-underline-offset: 2px; +} + +.st-markdown-table-wrap { + max-width: 100%; + margin: var(--sp-2) 0; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--r-2); +} + +.st-row-agent table { + width: 100%; + border-collapse: collapse; + font-size: var(--fs-12); +} + +.st-row-agent th, +.st-row-agent td { + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; + white-space: nowrap; +} + +.st-row-agent th { + color: var(--ink); + background: var(--surface-2); + font-weight: 600; +} + +.st-row-agent tr:last-child td { + border-bottom: 0; +} + .st-row-agent code { background: var(--surface-2); border: 1px solid var(--border);
ModelOpus