diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6f4a61b3..605bcabf 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,10 @@ - `codeBlockIndent` property on `MarkdownTheme` to customize code block content indentation (default: 2 spaces) +### Fixed + +- Autolinked emails no longer display redundant `(mailto:...)` suffix in markdown output + ## [0.49.2] - 2026-01-19 ## [0.49.1] - 2026-01-18 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 8e63fa94..e30ea79b 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -379,7 +379,10 @@ export class Markdown implements Component { const linkText = this.renderInlineTokens(token.tokens || []); // If link text matches href, only show the link once // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes - if (token.text === token.href) { + // 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(); } else { result += diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 56b86698..d1cd4316 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -644,6 +644,56 @@ again, hello world`, }); }); + describe("Links", () => { + it("should not duplicate URL for autolinked emails", () => { + const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // Should contain the email once, not duplicated with mailto: + assert.ok(joinedPlain.includes("user@example.com"), "Should contain email"); + assert.ok(!joinedPlain.includes("mailto:"), "Should not show mailto: prefix for autolinked emails"); + }); + + it("should not duplicate URL for bare URLs", () => { + const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // URL should appear only once + const urlCount = (joinedPlain.match(/https:\/\/example\.com/g) || []).length; + assert.strictEqual(urlCount, 1, "URL should appear exactly once"); + }); + + it("should show URL for explicit markdown links with different text", () => { + const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // Should show both link text and URL + assert.ok(joinedPlain.includes("click here"), "Should contain link text"); + assert.ok(joinedPlain.includes("(https://example.com)"), "Should show URL in parentheses"); + }); + + it("should show URL for explicit mailto links with different text", () => { + const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // Should show both link text and mailto URL + assert.ok(joinedPlain.includes("Email me"), "Should contain link text"); + assert.ok(joinedPlain.includes("(mailto:test@example.com)"), "Should show mailto URL in parentheses"); + }); + }); + 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,