diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2f352b40c..dbf11e363 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed an unclosed `$$` display-math block swallowing or corrupting every Markdown table after it. A block-math token no longer spans a GFM table, and the streaming pending-math fallback no longer treats a following table as pending math ([#11159](https://github.com/earendil-works/pi/issues/11159)) - Fixed Markdown bold and italic not rendering next to CJK punctuation ([#10730](https://github.com/earendil-works/pi/pull/10730) by [@theBucky](https://github.com/theBucky)) ## [1.3.0] - 2026-10-15 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 8782741f3..aa2166277 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -99,23 +99,41 @@ function tokenizeInlineLatex(source: string): LatexToken | undefined { return { type: "latex", raw, text }; } +const TABLE_DELIMITER_ROW_REGEX = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/; + +// A GFM table starts with a header row followed by a delimiter row such as "|---|---|" or "| --- |". +// The header needs a "|" only when the delimiter has none: a pipe-free delimiter without a pipe in the +// header is a thematic break, not a table. +function containsTableStart(source: string): boolean { + const lines = source.split("\n"); + for (let index = 0; index < lines.length - 1; index++) { + const delimiterRow = lines[index + 1]; + if ((delimiterRow.includes("|") || lines[index].includes("|")) && TABLE_DELIMITER_ROW_REGEX.test(delimiterRow)) { + return true; + } + } + return false; +} + function tokenizeBlockLatex(source: string): LatexToken | undefined { + // Display math never spans a table. Without this check, an unclosed "$$" or "\[" pairs with a later + // opener or stays pending, swallowing the table. See https://github.com/earendil-works/pi/issues/11159. const dollarMatch = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*?)\$\$[ \t]*(?:\n|$)/.exec(source); - if (dollarMatch?.[1]) { + if (dollarMatch?.[1] && !containsTableStart(dollarMatch[1])) { return { type: "latexBlock", raw: dollarMatch[0], text: dollarMatch[1].trim() }; } const bracketMatch = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*?)\\\][ \t]*(?:\n|$)/.exec(source); - if (bracketMatch?.[1]) { + if (bracketMatch?.[1] && !containsTableStart(bracketMatch[1])) { return { type: "latexBlock", raw: bracketMatch[0], text: bracketMatch[1].trim() }; } const pendingBracket = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*)$/.exec(source); - if (pendingBracket) { + if (pendingBracket?.[1] && !containsTableStart(pendingBracket[1])) { return { type: "latexBlock", raw: pendingBracket[0], text: pendingBracket[1], pending: true }; } const pendingDollar = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*)$/.exec(source); - if (pendingDollar?.[1] && looksLikePendingDollarMath(pendingDollar[1])) { + if (pendingDollar?.[1] && looksLikePendingDollarMath(pendingDollar[1]) && !containsTableStart(pendingDollar[1])) { return { type: "latexBlock", raw: pendingDollar[0], text: pendingDollar[1], pending: true }; } return undefined; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 96fbd958d..1cc43381b 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -966,6 +966,75 @@ Inline \(A_{sf}\)`, ); }); + // Regression tests for https://github.com/earendil-works/pi/issues/11159 + describe("unclosed display math before tables (#11159)", () => { + const table = ["| A | B |", "|---|---|", "| 1 | 2 |"]; + const renderedTable = ["┌───┬───┐", "│ A │ B │", "├───┼───┤", "│ 1 │ 2 │", "└───┴───┘"]; + const render = (source: string) => + new Markdown(source, 0, 0, defaultMarkdownTheme).render(80).map((line) => stripAnsi(line).trimEnd()); + + it("renders a table after an orphan $$ opener", () => { + const source = ["Intro", "", "$$", "", ...table, "", "After"].join("\n"); + assert.deepStrictEqual(render(source), ["Intro", "", "$$", "", ...renderedTable, "", "After"]); + }); + + it("renders a table after an orphan $$ opener with math-like content", () => { + const source = ["$$", "x = 1", "", ...table].join("\n"); + assert.deepStrictEqual(render(source), ["$$", "x = 1", "", ...renderedTable]); + }); + + it("does not pair an unclosed $$ with a later $$ across a table", () => { + const source = ["Intro", "", "$$", "x = 1", "", ...table, "", "$$", "y^2", "$$"].join("\n"); + assert.deepStrictEqual(render(source), ["Intro", "", "$$", "x = 1", "", ...renderedTable, "", "y²"]); + }); + + it("renders a single-column table with a pipe-free aligned delimiter after an orphan $$", () => { + const source = ["$$", "", "| A |", ":---:", "| 1 |"].join("\n"); + assert.deepStrictEqual(render(source), ["$$", "", "┌───┐", "│ A │", "├───┤", "│ 1 │", "└───┘"]); + }); + + it("renders a table after an orphan \\[ opener", () => { + const source = ["\\\\[", "x^2", "", ...table].join("\n"); + assert.deepStrictEqual(render(source), ["", "\\[", "x^2", "", ...renderedTable]); + }); + + it("does not pair an unclosed \\[ with a later \\[ across a table", () => { + const source = ["\\\\[", "x = 1", "", ...table, "", "\\\\[", "y^2", "\\\\]"].join("\n"); + assert.deepStrictEqual(render(source), [ + "", + "\\[", + "x = 1", + "", + ...renderedTable, + "", + "\\", + "", + "\\[", + "y^2", + "\\\\]", + ]); + }); + + it("renders a table with a pipe-free header after an orphan $$", () => { + const source = ["$$", "x^2", "", "Value", "| --- |", "1"].join("\n"); + assert.deepStrictEqual(render(source), [ + "$$", + "x^2", + "", + "┌───────┐", + "│ Value │", + "├───────┤", + "│ 1 │", + "└───────┘", + ]); + }); + + it("still renders closed multi-line display math followed by a table", () => { + const source = ["$$", "a", "+ b", "$$", "", ...table].join("\n"); + assert.deepStrictEqual(render(source), ["a + b", "", ...renderedTable]); + }); + }); + it("switches from raw to rendered math when a streamed delimiter closes", () => { const markdown = new Markdown(String.raw`Map $\mathbb{C}^3`, 0, 0, defaultMarkdownTheme); assert.deepStrictEqual(