diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index cb9aef52..51c8d696 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -245,7 +245,9 @@ export class Markdown implements Component { styledHeading = this.theme.heading(this.theme.bold(headingPrefix + headingText)); } lines.push(styledHeading); - lines.push(""); // Add spacing after headings + if (nextTokenType !== "space") { + lines.push(""); // Add spacing after headings (unless space token follows) + } break; } @@ -293,13 +295,17 @@ export class Markdown implements Component { for (const quoteLine of quoteLines) { lines.push(this.theme.quoteBorder("│ ") + this.theme.quote(this.theme.italic(quoteLine))); } - lines.push(""); // Add spacing after blockquotes + if (nextTokenType !== "space") { + lines.push(""); // Add spacing after blockquotes (unless space token follows) + } break; } case "hr": lines.push(this.theme.hr("─".repeat(Math.min(width, 80)))); - lines.push(""); // Add spacing after horizontal rules + if (nextTokenType !== "space") { + lines.push(""); // Add spacing after horizontal rules (unless space token follows) + } break; case "html": diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index c0e16d0a..aaa1fa37 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -285,6 +285,94 @@ again, hello world`, }); }); + describe("Spacing after dividers", () => { + it("should have only one blank line between divider and following paragraph", () => { + const markdown = new Markdown( + `hello world + +--- + +again, hello world`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + const dividerIndex = plainLines.findIndex((line) => line.includes("─")); + assert.ok(dividerIndex !== -1, "Should have divider"); + + const afterDivider = plainLines.slice(dividerIndex + 1); + const emptyLineCount = afterDivider.findIndex((line) => line !== ""); + + assert.strictEqual( + emptyLineCount, + 1, + `Expected 1 empty line after divider, but found ${emptyLineCount}. Lines after divider: ${JSON.stringify(afterDivider.slice(0, 5))}`, + ); + }); + }); + + describe("Spacing after headings", () => { + it("should have only one blank line between heading and following paragraph", () => { + const markdown = new Markdown( + `# Hello + +This is a paragraph`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + const headingIndex = plainLines.findIndex((line) => line.includes("Hello")); + assert.ok(headingIndex !== -1, "Should have heading"); + + const afterHeading = plainLines.slice(headingIndex + 1); + const emptyLineCount = afterHeading.findIndex((line) => line !== ""); + + assert.strictEqual( + emptyLineCount, + 1, + `Expected 1 empty line after heading, but found ${emptyLineCount}. Lines after heading: ${JSON.stringify(afterHeading.slice(0, 5))}`, + ); + }); + }); + + describe("Spacing after blockquotes", () => { + it("should have only one blank line between blockquote and following paragraph", () => { + const markdown = new Markdown( + `hello world + +> This is a quote + +again, hello world`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + const quoteIndex = plainLines.findIndex((line) => line.includes("This is a quote")); + assert.ok(quoteIndex !== -1, "Should have blockquote"); + + const afterQuote = plainLines.slice(quoteIndex + 1); + const emptyLineCount = afterQuote.findIndex((line) => line !== ""); + + assert.strictEqual( + emptyLineCount, + 1, + `Expected 1 empty line after blockquote, but found ${emptyLineCount}. Lines after quote: ${JSON.stringify(afterQuote.slice(0, 5))}`, + ); + }); + }); + describe("HTML-like tags in text", () => { it("should render content with HTML-like tags as text", () => { // When the model emits something like content in regular text,