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("| Model | ");
+ expect(html).toContain("Opus | ");
+ });
+
+ 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);