fix(tui): recover reports after orphan fences

This commit is contained in:
usr-bin-roygbiv
2026-08-14 02:03:18 +00:00
parent b1ce77c109
commit bdbea3f7f3
4 changed files with 139 additions and 1 deletions
@@ -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
+4
View File
@@ -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
+62 -1
View File
@@ -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
+48
View File
@@ -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 |