From 20ca6836b039447b8860f9a0687c853db1856492 Mon Sep 17 00:00:00 2001 From: Fero Date: Fri, 30 Jan 2026 03:02:38 +0100 Subject: [PATCH] fix(tui): blockquote multiline rendering and wrapping (#1073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tui): blockquote multiline rendering and wrapping Fix two bugs in blockquote rendering: 1. ANSI codes split incorrectly on newlines - when text tokens contain newlines, applyDefaultStyle() wrapped the entire string including the newline in ANSI codes. Any caller splitting by \n would break the codes. Fixed by splitting text by newlines before styling in renderInlineTokens(). 2. Wrapped blockquote lines lost the │ border - long blockquote lines that wrapped to multiple lines only had the border on the first line. Fixed by wrapping within the blockquote case and adding border to each line. * test(tui): add test for inline formatting inside blockquotes --- packages/tui/src/components/markdown.ts | 81 +++++++++---- packages/tui/test/markdown.test.ts | 154 ++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 21 deletions(-) diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 2ebdc309..9bb15b1c 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -46,6 +46,11 @@ export interface MarkdownTheme { codeBlockIndent?: string; } +interface InlineStyleContext { + applyText: (text: string) => string; + stylePrefix: string; +} + export class Markdown implements Component { private text: string; private paddingX: number; // Left/right padding @@ -241,6 +246,20 @@ export class Markdown implements Component { return this.defaultStylePrefix; } + private getStylePrefix(styleFn: (text: string) => string): string { + const sentinel = "\u0000"; + const styled = styleFn(sentinel); + const sentinelIndex = styled.indexOf(sentinel); + return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + } + + private getDefaultInlineStyleContext(): InlineStyleContext { + return { + applyText: (text: string) => this.applyDefaultStyle(text), + stylePrefix: this.getDefaultStylePrefix(), + }; + } + private renderToken(token: Token, width: number, nextTokenType?: string): string[] { const lines: string[] = []; @@ -311,10 +330,23 @@ export class Markdown implements Component { } case "blockquote": { - const quoteText = this.renderInlineTokens(token.tokens || []); + const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text)); + const quoteStyleContext: InlineStyleContext = { + applyText: quoteStyle, + stylePrefix: this.getStylePrefix(quoteStyle), + }; + const quoteText = this.renderInlineTokens(token.tokens || [], quoteStyleContext); const quoteLines = quoteText.split("\n"); + + // Calculate available width for quote content (subtract border "│ " = 2 chars) + const quoteContentWidth = Math.max(1, width - 2); + for (const quoteLine of quoteLines) { - lines.push(this.theme.quoteBorder("│ ") + this.theme.quote(this.theme.italic(quoteLine))); + // Wrap the styled line, then add border to each wrapped line + const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth); + for (const wrappedLine of wrappedLines) { + lines.push(this.theme.quoteBorder("│ ") + wrappedLine); + } } if (nextTokenType !== "space") { lines.push(""); // Add spacing after blockquotes (unless space token follows) @@ -351,54 +383,61 @@ export class Markdown implements Component { return lines; } - private renderInlineTokens(tokens: Token[]): string { + private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string { let result = ""; + const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext(); + const { applyText, stylePrefix } = resolvedStyleContext; + const applyTextWithNewlines = (text: string): string => { + const segments: string[] = text.split("\n"); + return segments.map((segment: string) => applyText(segment)).join("\n"); + }; for (const token of tokens) { switch (token.type) { case "text": // Text tokens in list items can have nested tokens for inline formatting if (token.tokens && token.tokens.length > 0) { - result += this.renderInlineTokens(token.tokens); + result += this.renderInlineTokens(token.tokens, resolvedStyleContext); } else { - // Apply default style to plain text - result += this.applyDefaultStyle(token.text); + result += applyTextWithNewlines(token.text); } break; + case "paragraph": + // Paragraph tokens contain nested inline tokens + result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + break; + case "strong": { - // Apply bold, then reapply default style after - const boldContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.bold(boldContent) + this.getDefaultStylePrefix(); + const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.bold(boldContent) + stylePrefix; break; } case "em": { - // Apply italic, then reapply default style after - const italicContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.italic(italicContent) + this.getDefaultStylePrefix(); + const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.italic(italicContent) + stylePrefix; break; } case "codespan": - // Apply code styling without backticks - result += this.theme.code(token.text) + this.getDefaultStylePrefix(); + result += this.theme.code(token.text) + stylePrefix; break; case "link": { - const linkText = this.renderInlineTokens(token.tokens || []); + const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); // If link text matches href, only show the link once // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes // For mailto: links, strip the prefix before comparing (autolinked emails have // text="foo@bar.com" but href="mailto:foo@bar.com") const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href; if (token.text === token.href || token.text === hrefForComparison) { - result += this.theme.link(this.theme.underline(linkText)) + this.getDefaultStylePrefix(); + result += this.theme.link(this.theme.underline(linkText)) + stylePrefix; } else { result += this.theme.link(this.theme.underline(linkText)) + this.theme.linkUrl(` (${token.href})`) + - this.getDefaultStylePrefix(); + stylePrefix; } break; } @@ -408,22 +447,22 @@ export class Markdown implements Component { break; case "del": { - const delContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.strikethrough(delContent) + this.getDefaultStylePrefix(); + const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.strikethrough(delContent) + stylePrefix; break; } case "html": // Render inline HTML as plain text if ("raw" in token && typeof token.raw === "string") { - result += this.applyDefaultStyle(token.raw); + result += applyTextWithNewlines(token.raw); } break; default: // Handle any other inline token types as plain text if ("text" in token && typeof token.text === "string") { - result += this.applyDefaultStyle(token.text); + result += applyTextWithNewlines(token.text); } } } diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 0de844ca..824ff3ca 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -691,6 +691,160 @@ again, hello world`, }); }); + describe("Blockquotes with multiline content", () => { + it("should apply consistent styling to all lines in lazy continuation blockquote", () => { + // Markdown "lazy continuation" - second line without > is still part of the quote + const markdown = new Markdown( + `>Foo +bar`, + 0, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.magenta(text), // This should NOT be applied to blockquotes + }, + ); + + const lines = markdown.render(80); + + // Both lines should have the quote border + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const quotedLines = plainLines.filter((line) => line.startsWith("│ ")); + assert.strictEqual(quotedLines.length, 2, `Expected 2 quoted lines, got: ${JSON.stringify(plainLines)}`); + + // Both lines should have italic (from theme.quote styling) + const fooLine = lines.find((line) => line.includes("Foo")); + const barLine = lines.find((line) => line.includes("bar")); + assert.ok(fooLine, "Should have Foo line"); + assert.ok(barLine, "Should have bar line"); + + // Check that both have italic (\x1b[3m) - blockquotes use theme styling, not default message color + assert.ok(fooLine?.includes("\x1b[3m"), `Foo line should have italic: ${fooLine}`); + assert.ok(barLine?.includes("\x1b[3m"), `bar line should have italic: ${barLine}`); + + // Blockquotes should NOT have the default message color (magenta) + assert.ok(!fooLine?.includes("\x1b[35m"), `Foo line should NOT have magenta color: ${fooLine}`); + assert.ok(!barLine?.includes("\x1b[35m"), `bar line should NOT have magenta color: ${barLine}`); + }); + + it("should apply consistent styling to explicit multiline blockquote", () => { + const markdown = new Markdown( + `>Foo +>bar`, + 0, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.cyan(text), // This should NOT be applied to blockquotes + }, + ); + + const lines = markdown.render(80); + + // Both lines should have the quote border + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const quotedLines = plainLines.filter((line) => line.startsWith("│ ")); + assert.strictEqual(quotedLines.length, 2, `Expected 2 quoted lines, got: ${JSON.stringify(plainLines)}`); + + // Both lines should have italic (from theme.quote styling) + const fooLine = lines.find((line) => line.includes("Foo")); + const barLine = lines.find((line) => line.includes("bar")); + assert.ok(fooLine?.includes("\x1b[3m"), `Foo line should have italic: ${fooLine}`); + assert.ok(barLine?.includes("\x1b[3m"), `bar line should have italic: ${barLine}`); + + // Blockquotes should NOT have the default message color (cyan) + assert.ok(!fooLine?.includes("\x1b[36m"), `Foo line should NOT have cyan color: ${fooLine}`); + assert.ok(!barLine?.includes("\x1b[36m"), `bar line should NOT have cyan color: ${barLine}`); + }); + + it("should wrap long blockquote lines and add border to each wrapped line", () => { + const longText = "This is a very long blockquote line that should wrap to multiple lines when rendered"; + const markdown = new Markdown(`> ${longText}`, 0, 0, defaultMarkdownTheme); + + // Render at narrow width to force wrapping + const lines = markdown.render(30); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Filter to non-empty lines (exclude trailing blank line after blockquote) + const contentLines = plainLines.filter((line) => line.length > 0); + + // Should have multiple lines due to wrapping + assert.ok(contentLines.length > 1, `Expected multiple wrapped lines, got: ${JSON.stringify(contentLines)}`); + + // Every content line should start with the quote border + for (const line of contentLines) { + assert.ok(line.startsWith("│ "), `Wrapped line should have quote border: "${line}"`); + } + + // All content should be preserved + const allText = contentLines.join(" "); + assert.ok(allText.includes("very long"), "Should preserve 'very long'"); + assert.ok(allText.includes("blockquote"), "Should preserve 'blockquote'"); + assert.ok(allText.includes("multiple"), "Should preserve 'multiple'"); + }); + + it("should properly indent wrapped blockquote lines with styling", () => { + const markdown = new Markdown( + "> This is styled text that is long enough to wrap", + 0, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.yellow(text), // This should NOT be applied to blockquotes + italic: true, + }, + ); + + const lines = markdown.render(25); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Filter to non-empty lines + const contentLines = plainLines.filter((line) => line.length > 0); + + // All lines should have the quote border + for (const line of contentLines) { + assert.ok(line.startsWith("│ "), `Line should have quote border: "${line}"`); + } + + // Check that italic is applied (from theme.quote) + const allOutput = lines.join("\n"); + assert.ok(allOutput.includes("\x1b[3m"), "Should have italic"); + + // Blockquotes should NOT have the default message color (yellow) + assert.ok(!allOutput.includes("\x1b[33m"), "Should NOT have yellow color from default style"); + }); + + it("should render inline formatting inside blockquotes and reapply quote styling after", () => { + const markdown = new Markdown("> Quote with **bold** and `code`", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + // Should have the quote border + assert.ok( + plainLines.some((line) => line.startsWith("│ ")), + "Should have quote border", + ); + + // Content should be preserved + const allPlain = plainLines.join(" "); + assert.ok(allPlain.includes("Quote with"), "Should preserve 'Quote with'"); + assert.ok(allPlain.includes("bold"), "Should preserve 'bold'"); + assert.ok(allPlain.includes("code"), "Should preserve 'code'"); + + const allOutput = lines.join("\n"); + + // Should have bold styling (\x1b[1m) + assert.ok(allOutput.includes("\x1b[1m"), "Should have bold styling"); + + // Should have code styling (yellow = \x1b[33m from defaultMarkdownTheme) + assert.ok(allOutput.includes("\x1b[33m"), "Should have code styling (yellow)"); + + // Should have italic from quote styling (\x1b[3m) + assert.ok(allOutput.includes("\x1b[3m"), "Should have italic from quote styling"); + }); + }); + describe("Links", () => { it("should not duplicate URL for autolinked emails", () => { const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme);