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);