diff --git a/packages/coding-agent/test/modes/components/assistant-message-streaming-fastpath.test.ts b/packages/coding-agent/test/modes/components/assistant-message-streaming-fastpath.test.ts index 0f961c90c..e8fa653b7 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-streaming-fastpath.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-streaming-fastpath.test.ts @@ -97,6 +97,31 @@ describe("AssistantMessageComponent streaming fast path", () => { } }); + it("repairs Gemini's lone closing fence when the streamed turn becomes final", () => { + const text = `=== PACED IP ROTATION SOAK RESULTS === +Average Latency: 1,240 ms +\`\`\` + +--- + +### Production Deployment Status + +| Workload | Pod Status | +| :--- | :--- | +| google-scraper | **1/1 Running** |`; + const message = msg([{ type: "text", text }]); + const component = new AssistantMessageComponent(); + + component.updateContent(message, { transient: true }); + expect(Bun.stripANSI(component.render(W).join("\n"))).toContain("| :--- | :--- |"); + + component.updateContent(message); + const finalized = Bun.stripANSI(component.render(W).join("\n")); + expect(finalized).not.toContain("| :--- | :--- |"); + expect(finalized).toContain("google-scraper"); + expect(finalized).toContain("1/1 Running"); + }); + // Regression: theme/symbol changes reach the component via invalidate() // (InteractiveMode clears the markdown render cache and invalidates the // tree). Reused fast-path children captured getMarkdownTheme() at diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 06f97341d..82d1764b7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Gemini reports rendering their final headings and tables as one raw code block when the model emitted a lone closing Markdown fence without its opener. + ## [17.3.1] - 2026-08-13 ### Fixed diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index dfdd7ab8a..fd39d42fb 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -43,6 +43,65 @@ function normalizeOsc8Terminators(text: string): string { return text.replace(OSC8_ST_PREFIX_REGEX, "$1\x07"); } +const MARKDOWN_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/; +const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6}[ \t]+\S/; +const FENCED_SOURCE_INTRO = /\b(?:code|example|markdown|output|snippet|source)\s*:?\s*$/i; + +function isGfmTableDelimiter(line: string): boolean { + const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, ""); + const cells = trimmed.split("|"); + return cells.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell.trim())); +} + +/** + * Gemini can emit a bare closing fence without its opener, then continue with + * headings and tables. CommonMark must interpret that lone fence as an opener, + * which turns the rest of an otherwise valid report into one raw code block. + * + * Repair only the unambiguous rich-document shape at final render: one + * unmatched bare fence after prose, followed by both an ATX heading and a GFM + * table delimiter. Keep ordinary incomplete code blocks, fenced Markdown + * examples, and every matched fence untouched. + */ +function repairOrphanClosingFence(text: string): string { + const lines = text.split("\n"); + let open: { index: number; marker: string; info: string } | undefined; + for (let index = 0; index < lines.length; index++) { + const match = MARKDOWN_FENCE_LINE.exec(lines[index]!); + if (!match) continue; + const marker = match[1]!; + const info = match[2]!.trim(); + if (!open) { + open = { index, marker, info }; + continue; + } + if (marker[0] === open.marker[0] && marker.length >= open.marker.length && info === "") { + open = undefined; + } + } + if (!open || open.info !== "") return text; + + let previous = ""; + for (let index = open.index - 1; index >= 0; index--) { + previous = lines[index]!.trim(); + if (previous) break; + } + if (!previous || previous.endsWith(":") || FENCED_SOURCE_INTRO.test(previous)) return text; + + let hasHeading = false; + let hasTableDelimiter = false; + for (let index = open.index + 1; index < lines.length; index++) { + const line = lines[index]!; + hasHeading ||= MARKDOWN_HEADING_LINE.test(line); + hasTableDelimiter ||= isGfmTableDelimiter(line); + if (hasHeading && hasTableDelimiter) { + lines.splice(open.index, 1); + return lines.join("\n"); + } + } + return text; +} + // OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible // unit by the H1 render path. Like image-protocol lines, they bypass ANSI // wrapping and width padding (see `isOsc66Line` in ../utils): re-wrapping @@ -1730,7 +1789,9 @@ export class Markdown } // Replace tabs with 3 spaces for consistent rendering - const normalizedText = replaceTabs(this.#text); + const normalizedText = this.transientRenderCache + ? replaceTabs(this.#text) + : repairOrphanClosingFence(replaceTabs(this.#text)); const signature = this.#renderSignature(width, paddingX); // L2: module-level LRU — survives component disposal/recreation across diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index f4d099f77..c5fcfacd2 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -254,6 +254,54 @@ describe("Markdown component", () => { expect(plainLines.some(line => line.includes("-"))).toBeTruthy(); }); + it("recovers rich Markdown after a lone closing fence from Gemini", () => { + const markdown = new Markdown( + `=== PACED IP ROTATION SOAK RESULTS === +Total Queries: 20 +Average Latency: 1,240 ms +\`\`\` + +--- + +### Production Deployment Status + +| Workload | Pod Status | +| :--- | :--- | +| google-scraper | **1/1 Running** |`, + 0, + 0, + defaultMarkdownTheme, + ); + markdown.transientRenderCache = true; + markdown.render(80); + + markdown.transientRenderCache = false; + const plainLines = markdown.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(plainLines.some(line => line.includes("| :--- | :--- |"))).toBe(false); + expect(plainLines.filter(line => line.includes("+")).length).toBeGreaterThanOrEqual(2); + expect(plainLines.some(line => line.includes("google-scraper") && line.includes("1/1 Running"))).toBe(true); + }); + + it("keeps an intentional unclosed fenced Markdown example literal", () => { + const markdown = new Markdown( + `Markdown source: +\`\`\` +### Production Deployment Status + +| Workload | Pod Status | +| :--- | :--- | +| google-scraper | 1/1 Running |`, + 0, + 0, + defaultMarkdownTheme, + ); + const plainLines = markdown.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(plainLines.some(line => line.includes("| :--- | :--- |"))).toBe(true); + expect(plainLines.filter(line => line.includes("+"))).toHaveLength(0); + }); + it("should render row dividers between data rows", () => { const markdown = new Markdown( `| Name | Age |