From 09fc72556d951732536dbfce5bda4f31063efa6f Mon Sep 17 00:00:00 2001 From: buenosair Date: Wed, 2 Sep 2026 09:49:56 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20markdown=20block=20parsing=20in=20LLM?= =?UTF-8?q?=20output=20=E2=80=94=20headings,=20lists=20(ul/ol),=20blockquo?= =?UTF-8?q?tes,=20hr=20on=20top=20of=20fences=20+=20inline;=20300=20tests?= =?UTF-8?q?=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/ChatStream.test.tsx | 60 +++++++++++++++++++ web/src/ChatStream.tsx | 111 +++++++++++++++++++++++++++++++++--- web/src/SpawnView.test.tsx | 43 +++++++------- web/src/index.css | 42 ++++++++++++++ 4 files changed, 226 insertions(+), 30 deletions(-) diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx index 618f364..cb70f30 100644 --- a/web/src/ChatStream.test.tsx +++ b/web/src/ChatStream.test.tsx @@ -1041,3 +1041,63 @@ describe("unified tool card", () => { expect(screen.queryByText("next msg")).toBeNull(); }); }); + +describe("renderMarkdown blocks", () => { + it("headings render as h1-h6 with inline content", () => { + const out = renderMarkdown("## Hello **world**"); + const h = out[0] as React.ReactElement<{ className: string }>; + expect(h.type).toBe("h2"); + expect(h.props.className).toContain("md-h-2"); + }); + + it("--- renders a horizontal rule", () => { + const out = renderMarkdown("above\n---\nbelow"); + expect(out.some((n) => (n as React.ReactElement).type === "hr")).toBe(true); + }); + + it("> lines group into one blockquote", () => { + const out = renderMarkdown("> quoted a\n> quoted b\nplain"); + const q = out.find( + (n) => (n as React.ReactElement).type === "blockquote", + ) as React.ReactElement<{ children: React.ReactNode[] }>; + expect(q).toBeDefined(); + expect(q.props.children.join("")).toContain("quoted a"); + expect(q.props.children.join("")).toContain("quoted b"); + }); + + it("- and * lines render as an unordered list", () => { + const out = renderMarkdown("- one\n- two\n* three"); + const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>; + expect(ul.type).toBe("ul"); + expect(ul.props.children).toHaveLength(3); + }); + + it("1. lines render as an ordered list", () => { + const out = renderMarkdown("1. first\n2. second"); + const ol = out[0] as React.ReactElement<{ children: React.ReactNode[] }>; + expect(ol.type).toBe("ol"); + expect(ol.props.children).toHaveLength(2); + }); + + it("emphasis start is not a list marker", () => { + const out = renderMarkdown("*bold start* to line"); + expect(out.some((n) => (n as React.ReactElement).type === "ul")).toBe(false); + }); + + it("list items keep inline markdown", () => { + const out = renderMarkdown("- has `code` and [l](https://x.io)"); + const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>; + const li = ul.props.children[0] as React.ReactElement<{ + children: React.ReactNode[]; + }>; + expect(JSON.stringify(li.props.children)).toContain("code"); + expect(JSON.stringify(li.props.children)).toContain("https://x.io"); + }); + + it("fences still win over block markers", () => { + const out = renderMarkdown("```\n- not a list\n# not a heading\n```"); + const pre = out[0] as React.ReactElement<{ children: string }>; + expect(pre.type).toBe("pre"); + expect(pre.props.children).toContain("- not a list"); + }); +}); diff --git a/web/src/ChatStream.tsx b/web/src/ChatStream.tsx index dd36070..3b50a52 100644 --- a/web/src/ChatStream.tsx +++ b/web/src/ChatStream.tsx @@ -6,6 +6,11 @@ const COPY_FEEDBACK_MS: number = 1200; const PIN_THRESHOLD_PX: number = 80; const FAB_THRESHOLD_PX: number = 400; const FENCE: string = "```"; +const HEADING_RE: RegExp = /^(#{1,6})\s+(.*)$/; +const HR_RE: RegExp = /^(?:-{3,}|\*{3,}|_{3,})$/; +const QUOTE_RE: RegExp = /^>\s?(.*)$/; +const UL_RE: RegExp = /^[-*+]\s+(.*)$/; +const OL_RE: RegExp = /^\d{1,9}[.)]\s+(.*)$/; const ENTER_KEY: string = "Enter"; const ESCAPE_KEY: string = "Escape"; const INLINE_RE: RegExp = @@ -79,11 +84,13 @@ function inlineNodes( return out; } -/** ```fences``` →
, `code`, **bold**, *italic*,
- *  [t](http…) links (http/https only). Plain segments keep the bubble's
- *  pre-wrap. Unterminated fences (streaming) render the tail as code. */
+/** Block markdown: ```fences```, # headings, --- rules, > quotes, -/*
+ *  lists, 1. lists; inline: `code`, **bold**, *italic*, [t](http…)
+ *  links (http/https only). Plain segments keep the bubble's pre-wrap.
+ *  Unterminated fences (streaming) render the tail as code. */
 export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
 	const out: React.ReactNode[] = [];
+	const lines: string[] = text.split("\n");
 	let plain: string[] = [];
 	let code: string[] | null = null;
 	let n = 0;
@@ -94,7 +101,29 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
 			plain = [];
 		}
 	};
-	for (const line of text.split("\n")) {
+	// consecutive lines sharing a marker accumulate into one block element
+	const collect = (
+		re: RegExp,
+		from: number,
+	): { items: string[]; next: number } => {
+		const items: string[] = [];
+		let k: number = from;
+		while (k < lines.length) {
+			const raw: string | undefined = lines[k];
+			if (raw === undefined) break;
+			const m: RegExpMatchArray | null = raw.match(re);
+			if (m === null) break;
+			const item: string | undefined = m[1];
+			if (item === undefined) break;
+			items.push(item);
+			k += 1;
+		}
+		return { items, next: k };
+	};
+	let i: number = 0;
+	while (i < lines.length) {
+		const line: string | undefined = lines[i];
+		if (line === undefined) break;
 		if (line.trimStart().startsWith(FENCE)) {
 			if (code === null) {
 				flushPlain();
@@ -108,11 +137,77 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
 				n += 1;
 				code = null;
 			}
-		} else if (code === null) {
-			plain.push(line);
-		} else {
-			code.push(line);
+			i += 1;
+			continue;
 		}
+		if (code !== null) {
+			code.push(line);
+			i += 1;
+			continue;
+		}
+		const heading: RegExpMatchArray | null = line.match(HEADING_RE);
+		if (heading !== null) {
+			flushPlain();
+			const level: number = Math.min(heading[1]?.length ?? 1, 6);
+			const Tag: keyof React.JSX.IntrinsicElements = `h${level}` as "h1";
+			out.push(
+				
+					{inlineNodes(heading[2] ?? "", query, `h-${n}`)}
+				,
+			);
+			n += 1;
+			i += 1;
+			continue;
+		}
+		if (HR_RE.test(line.trim())) {
+			flushPlain();
+			out.push(
); + n += 1; + i += 1; + continue; + } + if (QUOTE_RE.test(line)) { + flushPlain(); + const q: { items: string[]; next: number } = collect(QUOTE_RE, i); + out.push( +
+ {inlineNodes(q.items.join("\n"), query, `q-${n}`)} +
, + ); + n += 1; + i = q.next; + continue; + } + if (UL_RE.test(line)) { + flushPlain(); + const l: { items: string[]; next: number } = collect(UL_RE, i); + out.push( + , + ); + n += 1; + i = l.next; + continue; + } + if (OL_RE.test(line)) { + flushPlain(); + const l: { items: string[]; next: number } = collect(OL_RE, i); + out.push( +
    + {l.items.map((item, k) => ( +
  1. {inlineNodes(item, query, `o-${n}-${k}`)}
  2. + ))} +
, + ); + n += 1; + i = l.next; + continue; + } + plain.push(line); + i += 1; } if (code === null) { flushPlain(); diff --git a/web/src/SpawnView.test.tsx b/web/src/SpawnView.test.tsx index 32f8405..d4a2d4b 100644 --- a/web/src/SpawnView.test.tsx +++ b/web/src/SpawnView.test.tsx @@ -1,4 +1,10 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Repo, RepoImage, SessionListItem } from "./protocol"; @@ -106,8 +112,7 @@ describe("SpawnView connect flow", () => { connected = true; return { username: "alice" }; } - if (url.endsWith("/api/gitlab/repos")) - return [repo("g/one"), repo("g/two")]; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")]; return []; }); render(tree(makeStore())); @@ -181,9 +186,7 @@ describe("SpawnView repo picker", () => { expect(screen.queryByText("g/alpha")).toBeNull(); expect(screen.getByText("g/beta")).toBeInTheDocument(); - const item = screen - .getByText("g/beta") - .closest(".repo-item") as HTMLElement; + const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement; fireEvent.keyDown(item, { key: "Enter" }); expect(screen.getByLabelText("Branch")).toHaveValue("dev"); @@ -195,9 +198,7 @@ describe("SpawnView repo picker", () => { connectedMock(); render(tree(makeStore())); await flush(); - const item = screen - .getByText("g/alpha") - .closest(".repo-item") as HTMLElement; + const item = screen.getByText("g/alpha").closest(".repo-item") as HTMLElement; const enter = fireEvent.keyDown(item, { key: "Enter" }); expect(enter).toBe(false); // preventDefault consumed: no page scroll @@ -269,7 +270,7 @@ describe("SpawnView spawn+poll", () => { repo: "g/proj", startedAt: 1, online: true, - busy: false, + busy: false, lastEventAt: 1, }, ] @@ -357,8 +358,7 @@ describe("SpawnView spawn+poll", () => { return []; }); const failingRefresh = makeStore({ - refresh: (): Promise => - Promise.reject(new Error("boom")), + refresh: (): Promise => Promise.reject(new Error("boom")), }); render(tree(failingRefresh)); await flush(); @@ -536,10 +536,7 @@ describe("SpawnView repo images (registry)", () => { const ok = screen.getByText("custom image"); expect(ok.className).toContain("ok"); - expect(ok).toHaveAttribute( - "title", - "custom image lvmh-worker-alpha — built", - ); + expect(ok).toHaveAttribute("title", "custom image lvmh-worker-alpha — built"); const warn = screen.getByText("needs build"); expect(warn.className).toContain("warn"); @@ -565,8 +562,7 @@ describe("SpawnView repo images (registry)", () => { const prepareCalls = fetchMock.mock.calls.filter( ([u, i]) => - String(u).endsWith("/api/repos/g/alpha/prepare") && - i?.method === "POST", + String(u).endsWith("/api/repos/g/alpha/prepare") && i?.method === "POST", ); expect(prepareCalls).toHaveLength(1); expect(PUSH_TOAST).toHaveBeenCalledWith( @@ -618,8 +614,7 @@ describe("SpawnView repo images (registry)", () => { if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "a" }; if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")]; - if (url.endsWith("/api/repos")) - return jsonResponse({ error: "boom" }, 500); + if (url.endsWith("/api/repos")) return jsonResponse({ error: "boom" }, 500); return []; }); render(tree(makeStore())); @@ -691,7 +686,9 @@ describe("spawn model selection", () => { await flush(); const sel = screen.getByLabelText("Initial model"); await waitFor(() => expect(sel.querySelectorAll("optgroup").length).toBe(2)); - expect(sel.querySelector('option[value="zai-renaud/glm-5.3"]')).not.toBeNull(); + expect( + sel.querySelector('option[value="zai-renaud/glm-5.3"]'), + ).not.toBeNull(); }); it("selected model is sent in the spawn body", async () => { @@ -712,7 +709,9 @@ describe("spawn model selection", () => { await flush(); fireEvent.click(screen.getByText("g/p")); const sel = screen.getByLabelText("Initial model"); - await waitFor(() => expect(sel.querySelectorAll("option").length).toBeGreaterThan(1)); + await waitFor(() => + expect(sel.querySelectorAll("option").length).toBeGreaterThan(1), + ); fireEvent.change(sel, { target: { value: "zai-renaud/glm-5.3" } }); fireEvent.click(screen.getByLabelText("Spawn container")); await flush(); diff --git a/web/src/index.css b/web/src/index.css index d23af4c..af270ca 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -777,6 +777,48 @@ details.thinking .thinking-body { overflow-x: auto; color: var(--text-dim); } +.bubble .md-h { + margin: 14px 0 6px; + line-height: 1.3; + color: var(--text); +} +.bubble .md-h:first-child { + margin-top: 4px; +} +.bubble .md-h-1 { + font-size: 1.35em; +} +.bubble .md-h-2 { + font-size: 1.22em; +} +.bubble .md-h-3 { + font-size: 1.1em; +} +.bubble .md-h-4, +.bubble .md-h-5, +.bubble .md-h-6 { + font-size: 1em; +} +.bubble .md-hr { + border: 0; + border-top: 1px solid var(--border); + margin: 12px 0; +} +.bubble .md-quote { + margin: 8px 0; + padding: 2px 0 2px 12px; + border-left: 3px solid var(--border-strong); + color: var(--text-dim); + white-space: pre-wrap; +} +.bubble .md-list { + margin: 8px 0; + padding-left: 22px; + white-space: pre-wrap; +} +.bubble .md-list li { + margin: 3px 0; +} .bubble code { font-family: var(--mono); font-size: 0.92em;