diff --git a/extensions/cli/src/ui/MarkdownRenderer.tsx b/extensions/cli/src/ui/MarkdownRenderer.tsx index 8cc2d2223da..eb451c59001 100644 --- a/extensions/cli/src/ui/MarkdownRenderer.tsx +++ b/extensions/cli/src/ui/MarkdownRenderer.tsx @@ -1,4 +1,4 @@ -import { Text } from "ink"; +import { Box, Text } from "ink"; import React from "react"; import { @@ -84,6 +84,17 @@ const MarkdownRenderer: React.FC = React.memo( ), }, + { + // Match URLs - must come after markdown patterns to avoid conflicts + // Matches http://, https://, and angle-bracket URLs like + // Uses a capturing group to match URL content, but we'll use match[0] for full text + regex: /]+)>?/g, + render: (content, key) => ( + + {content} + + ), + }, ]; const renderMarkdown = (text: string | null | undefined) => { @@ -121,6 +132,7 @@ const MarkdownRenderer: React.FC = React.memo( index: number; length: number; content: string; + fullMatch: string; render: (content: string, key: string) => React.ReactNode; }> = []; @@ -138,10 +150,14 @@ const MarkdownRenderer: React.FC = React.memo( ); if (!isInCodeBlock) { + // For URLs, use the full match to preserve angle brackets + // For other patterns, use capture groups as before + const isUrlPattern = pattern.regex.source.includes("https?"); allMatches.push({ index: match.index, length: match[0].length, - content: match[2] || match[1], // Use second capture group for headings, first for others + content: isUrlPattern ? match[0] : match[2] || match[1], // Use full match for URLs, capture groups for others + fullMatch: match[0], render: pattern.render, }); } @@ -154,6 +170,7 @@ const MarkdownRenderer: React.FC = React.memo( length: number; type: "code" | "other"; content?: string; + fullMatch?: string; render?: (content: string, key: string) => React.ReactNode; language?: string; code?: string; @@ -170,6 +187,7 @@ const MarkdownRenderer: React.FC = React.memo( length: match.length, type: "other" as const, content: match.content, + fullMatch: match.fullMatch, render: match.render, })), ]; @@ -227,7 +245,32 @@ const MarkdownRenderer: React.FC = React.memo( return parts; }; - return {renderMarkdown(content)}; + const parts = renderMarkdown(content); + + // Check if content has URLs by looking for cyan-colored Text elements + const hasUrls = parts.some( + (part) => + React.isValidElement(part) && (part as any).props?.color === "cyan", + ); + + // Use Box layout when URLs are present to prevent them from breaking + // across lines. Each element (URL or text) becomes a separate flex item. + if (hasUrls) { + return ( + + {parts.map((part, idx) => + React.isValidElement(part) ? ( + part + ) : ( + {part} + ), + )} + + ); + } + + // No URLs: use original Text wrapping for backward compatibility + return {parts}; }, ); diff --git a/extensions/cli/src/ui/MarkdownRenderer.url.test.tsx b/extensions/cli/src/ui/MarkdownRenderer.url.test.tsx new file mode 100644 index 00000000000..3e507df3c65 --- /dev/null +++ b/extensions/cli/src/ui/MarkdownRenderer.url.test.tsx @@ -0,0 +1,93 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; + +import { MarkdownRenderer } from "./MarkdownRenderer.js"; + +describe("MarkdownRenderer - URL handling", () => { + it("should render URLs without breaking", () => { + const content = + "Check out this link: https://github.com/continuedev/continue/discussions/8240"; + + const { lastFrame } = render(); + const frame = lastFrame(); + + // URL should be present and complete + expect(frame).toContain( + "https://github.com/continuedev/continue/discussions/8240", + ); + // Frame should contain the prefix text + expect(frame).toContain("Check out this link:"); + }); + + it("should handle angle-bracket URLs", () => { + const content = + "expectation would be the link "; + + const { lastFrame } = render(); + const frame = lastFrame(); + + // URL should be extracted and rendered, with angle brackets preserved + expect(frame).toContain( + "https://github.com/continuedev/continue/discussions/8240", + ); + }); + + it("should handle multiple URLs in the same content", () => { + const content = + "Check https://example.com and also https://github.com/continuedev/continue"; + + const { lastFrame } = render(); + const frame = lastFrame(); + + expect(frame).toContain("https://example.com"); + expect(frame).toContain("https://github.com/continuedev/continue"); + }); + + it("should handle URLs with markdown text", () => { + const content = + "This is **bold text** and this is a link: https://continue.dev and more text."; + + const { lastFrame } = render(); + const frame = lastFrame(); + + expect(frame).toContain("bold text"); + expect(frame).toContain("https://continue.dev"); + }); + + it("should handle http URLs", () => { + const content = "Visit http://localhost:3000/api/endpoint"; + + const { lastFrame } = render(); + const frame = lastFrame(); + + expect(frame).toContain("http://localhost:3000/api/endpoint"); + }); + + it("should use Box layout when URLs are present", () => { + const content = "Link: https://example.com"; + + const { lastFrame } = render(); + const frame = lastFrame(); + + // Should contain the URL + expect(frame).toContain("https://example.com"); + // Should render without errors + expect(frame).toBeTruthy(); + }); + + it("should not break URLs across lines", () => { + // This is more of an integration test - the actual line breaking + // depends on terminal width, but we can verify the structure + const content = + "A very long URL: https://github.com/continuedev/continue/discussions/8240/with/many/path/segments"; + + const { lastFrame } = render(); + const frame = lastFrame(); + + // The URL should be present as a complete unit + expect(frame).toContain( + "https://github.com/continuedev/continue/discussions/8240/with/many/path/segments", + ); + }); +});