diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 11449b18..b2c816a9 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { Text, Box } from 'ink'; -import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { Colors } from '../../colors.js'; interface GeminiMessageProps { @@ -16,7 +16,6 @@ interface GeminiMessageProps { export const GeminiMessage: React.FC = ({ text }) => { const prefix = '✦ '; const prefixWidth = prefix.length; - const renderedBlocks = MarkdownRenderer.render(text); return ( @@ -24,7 +23,7 @@ export const GeminiMessage: React.FC = ({ text }) => { {prefix} - {renderedBlocks} + ); diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx index fb025231..b9b85dc7 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { Box } from 'ink'; -import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; interface GeminiMessageContentProps { text: string; @@ -23,11 +23,10 @@ export const GeminiMessageContent: React.FC = ({ }) => { const originalPrefix = '✦ '; const prefixWidth = originalPrefix.length; - const renderedBlocks = MarkdownRenderer.render(text); return ( - {renderedBlocks} + ); }; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 7c4b1d6f..3b58c052 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -10,7 +10,7 @@ import Spinner from 'ink-spinner'; import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js'; import { DiffRenderer } from './DiffRenderer.js'; import { Colors } from '../../colors.js'; -import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; export const ToolMessage: React.FC = ({ name, @@ -60,7 +60,7 @@ export const ToolMessage: React.FC = ({ {/* Use default text color (white) or gray instead of dimColor */} {typeof resultDisplay === 'string' && ( - {MarkdownRenderer.render(resultDisplay)} + )} {typeof resultDisplay === 'object' && ( diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx new file mode 100644 index 00000000..4e49a013 --- /dev/null +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -0,0 +1,301 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Text, Box } from 'ink'; +import { Colors } from '../colors.js'; +import { colorizeCode } from './CodeColorizer.js'; + +interface MarkdownDisplayProps { + text: string; +} + +function MarkdownDisplayComponent({ + text, +}: MarkdownDisplayProps): React.ReactElement { + if (!text) return <>; + + const lines = text.split('\n'); + const headerRegex = /^ *(#{1,4}) +(.*)/; + const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; + const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; + const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; + const hrRegex = /^ *([-*_] *){3,} *$/; + + const contentBlocks: React.ReactNode[] = []; + let inCodeBlock = false; + let codeBlockContent: string[] = []; + let codeBlockLang: string | null = null; + let codeBlockFence = ''; + + lines.forEach((line, index) => { + const key = `line-${index}`; + + if (inCodeBlock) { + const fenceMatch = line.match(codeFenceRegex); + if ( + fenceMatch && + fenceMatch[1].startsWith(codeBlockFence[0]) && + fenceMatch[1].length >= codeBlockFence.length + ) { + contentBlocks.push( + _renderCodeBlock(key, codeBlockContent, codeBlockLang), + ); + inCodeBlock = false; + codeBlockContent = []; + codeBlockLang = null; + codeBlockFence = ''; + } else { + codeBlockContent.push(line); + } + return; + } + + const codeFenceMatch = line.match(codeFenceRegex); + const headerMatch = line.match(headerRegex); + const ulMatch = line.match(ulItemRegex); + const olMatch = line.match(olItemRegex); + const hrMatch = line.match(hrRegex); + + if (codeFenceMatch) { + inCodeBlock = true; + codeBlockFence = codeFenceMatch[1]; + codeBlockLang = codeFenceMatch[2] || null; + } else if (hrMatch) { + contentBlocks.push( + + --- + , + ); + } else if (headerMatch) { + const level = headerMatch[1].length; + const headerText = headerMatch[2]; + const renderedHeaderText = _renderInline(headerText); + let headerNode: React.ReactNode = null; + switch (level) { + case 1: + headerNode = ( + + {renderedHeaderText} + + ); + break; + case 2: + headerNode = ( + + {renderedHeaderText} + + ); + break; + case 3: + headerNode = {renderedHeaderText}; + break; + case 4: + headerNode = ( + + {renderedHeaderText} + + ); + break; + default: + headerNode = {renderedHeaderText}; + break; + } + if (headerNode) contentBlocks.push({headerNode}); + } else if (ulMatch) { + const leadingWhitespace = ulMatch[1]; + const marker = ulMatch[2]; + const itemText = ulMatch[3]; + contentBlocks.push( + _renderListItem(key, itemText, 'ul', marker, leadingWhitespace), + ); + } else if (olMatch) { + const leadingWhitespace = olMatch[1]; + const marker = olMatch[2]; + const itemText = olMatch[3]; + contentBlocks.push( + _renderListItem(key, itemText, 'ol', marker, leadingWhitespace), + ); + } else { + const renderedLine = _renderInline(line); + if (renderedLine.length > 0 || line.length > 0) { + contentBlocks.push( + + {renderedLine} + , + ); + } else if (line.trim().length === 0) { + if (contentBlocks.length > 0 && !inCodeBlock) { + contentBlocks.push(); + } + } + } + }); + + if (inCodeBlock) { + contentBlocks.push( + _renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang), + ); + } + + return <>{contentBlocks}; +} + +// Helper functions (adapted from static methods of MarkdownRenderer) + +function _renderInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + const inlineRegex = + /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>)/g; + let match; + + while ((match = inlineRegex.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push( + + {text.slice(lastIndex, match.index)} + , + ); + } + + const fullMatch = match[0]; + let renderedNode: React.ReactNode = null; + const key = `m-${match.index}`; + + try { + if ( + fullMatch.startsWith('**') && + fullMatch.endsWith('**') && + fullMatch.length > 4 + ) { + renderedNode = ( + + {fullMatch.slice(2, -2)} + + ); + } else if ( + ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || + (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && + fullMatch.length > 2 + ) { + renderedNode = ( + + {fullMatch.slice(1, -1)} + + ); + } else if ( + fullMatch.startsWith('~~') && + fullMatch.endsWith('~~') && + fullMatch.length > 4 + ) { + renderedNode = ( + + {fullMatch.slice(2, -2)} + + ); + } else if ( + fullMatch.startsWith('`') && + fullMatch.endsWith('`') && + fullMatch.length > 1 + ) { + const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); + if (codeMatch && codeMatch[2]) { + renderedNode = ( + + {codeMatch[2]} + + ); + } else { + renderedNode = ( + + {fullMatch.slice(1, -1)} + + ); + } + } else if ( + fullMatch.startsWith('[') && + fullMatch.includes('](') && + fullMatch.endsWith(')') + ) { + const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); + if (linkMatch) { + const linkText = linkMatch[1]; + const url = linkMatch[2]; + renderedNode = ( + + {linkText} + ({url}) + + ); + } + } else if ( + fullMatch.startsWith('') && + fullMatch.endsWith('') && + fullMatch.length > 6 + ) { + renderedNode = ( + + {fullMatch.slice(3, -4)} + + ); + } + } catch (e) { + console.error('Error parsing inline markdown part:', fullMatch, e); + renderedNode = null; + } + + nodes.push(renderedNode ?? {fullMatch}); + lastIndex = inlineRegex.lastIndex; + } + + if (lastIndex < text.length) { + nodes.push({text.slice(lastIndex)}); + } + + return nodes.filter((node) => node !== null); +} + +function _renderCodeBlock( + key: string, + content: string[], + lang: string | null, +): React.ReactNode { + const fullContent = content.join('\n'); + const colorizedCode = colorizeCode(fullContent, lang); + + return ( + + {colorizedCode} + + ); +} + +function _renderListItem( + key: string, + text: string, + type: 'ul' | 'ol', + marker: string, + leadingWhitespace: string = '', +): React.ReactNode { + const renderedText = _renderInline(text); + const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; + const prefixWidth = prefix.length; + const indentation = leadingWhitespace.length; + + return ( + + + {prefix} + + + {renderedText} + + + ); +} + +export const MarkdownDisplay = React.memo(MarkdownDisplayComponent); diff --git a/packages/cli/src/ui/utils/MarkdownRenderer.tsx b/packages/cli/src/ui/utils/MarkdownRenderer.tsx deleted file mode 100644 index e1a48042..00000000 --- a/packages/cli/src/ui/utils/MarkdownRenderer.tsx +++ /dev/null @@ -1,369 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { Text, Box } from 'ink'; -import { Colors } from '../colors.js'; -import { colorizeCode } from './CodeColorizer.js'; - -/** - * A utility class to render a subset of Markdown into Ink components. - * Handles H1-H4, Lists (ul/ol, no nesting), Code Blocks, - * and inline styles (bold, italic, strikethrough, code, links). - */ -export class MarkdownRenderer { - /** - * Renders INLINE markdown elements using an iterative approach. - * Supports: **bold**, *italic*, _italic_, ~~strike~~, [link](url), `code`, ``code``, underline - * @param text The string segment to parse for inline styles. - * @returns An array of React nodes (Text components or strings). - */ - private static _renderInline(text: string): React.ReactNode[] { - const nodes: React.ReactNode[] = []; - let lastIndex = 0; - const inlineRegex = - /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>)/g; - let match; - - while ((match = inlineRegex.exec(text)) !== null) { - // 1. Add plain text before the match - if (match.index > lastIndex) { - nodes.push( - - {text.slice(lastIndex, match.index)} - , - ); - } - - const fullMatch = match[0]; - let renderedNode: React.ReactNode = null; - const key = `m-${match.index}`; // Base key for matched part - - // 2. Determine type of match and render accordingly - try { - if ( - fullMatch.startsWith('**') && - fullMatch.endsWith('**') && - fullMatch.length > 4 - ) { - renderedNode = ( - - {fullMatch.slice(2, -2)} - - ); - } else if ( - ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || - (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && - fullMatch.length > 2 - ) { - renderedNode = ( - - {fullMatch.slice(1, -1)} - - ); - } else if ( - fullMatch.startsWith('~~') && - fullMatch.endsWith('~~') && - fullMatch.length > 4 - ) { - // Strikethrough as gray text - renderedNode = ( - - {fullMatch.slice(2, -2)} - - ); - } else if ( - fullMatch.startsWith('`') && - fullMatch.endsWith('`') && - fullMatch.length > 1 - ) { - // Code: Try to match varying numbers of backticks - const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); - if (codeMatch && codeMatch[2]) { - renderedNode = ( - - {codeMatch[2]} - - ); - } else { - // Fallback for simple or non-matching cases - renderedNode = ( - - {fullMatch.slice(1, -1)} - - ); - } - } else if ( - fullMatch.startsWith('[') && - fullMatch.includes('](') && - fullMatch.endsWith(')') - ) { - // Link: Extract text and URL - const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); - if (linkMatch) { - const linkText = linkMatch[1]; - const url = linkMatch[2]; - // Render link text then URL slightly dimmed/colored - renderedNode = ( - - {linkText} - ({url}) - - ); - } - } else if ( - fullMatch.startsWith('') && - fullMatch.endsWith('') && - fullMatch.length > 6 - ) { - // ***** NEW: Handle underline tag ***** - // Use slice(3, -4) to remove and - renderedNode = ( - - {fullMatch.slice(3, -4)} - - ); - } - } catch (e) { - // In case of regex or slicing errors, fallback to literal rendering - console.error('Error parsing inline markdown part:', fullMatch, e); - renderedNode = null; // Ensure fallback below is used - } - - // 3. Add the rendered node or the literal text if parsing failed - nodes.push(renderedNode ?? {fullMatch}); - lastIndex = inlineRegex.lastIndex; // Move index past the current match - } - - // 4. Add any remaining plain text after the last match - if (lastIndex < text.length) { - nodes.push({text.slice(lastIndex)}); - } - - // Filter out potential nulls if any error occurred without fallback - return nodes.filter((node) => node !== null); - } - - /** - * Helper to render a code block. - */ - private static _renderCodeBlock( - key: string, - content: string[], - lang: string | null, - ): React.ReactNode { - const fullContent = content.join('\n'); - const colorizedCode = colorizeCode(fullContent, lang); - - return ( - - {colorizedCode} - - ); - } - - /** - * Helper to render a list item (ordered or unordered). - */ - private static _renderListItem( - key: string, - text: string, - type: 'ul' | 'ol', - marker: string, - leadingWhitespace: string = '', - ): React.ReactNode { - const renderedText = MarkdownRenderer._renderInline(text); // Allow inline styles in list items - const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; // e.g., "1. " or "* " - const prefixWidth = prefix.length; - const indentation = leadingWhitespace.length; - - return ( - - - {prefix} - - - {renderedText} - - - ); - } - - /** - * Renders a full markdown string, handling block elements (headers, lists, code blocks) - * and applying inline styles. This is the main public static method. - * @param text The full markdown string to render. - * @returns An array of React nodes representing markdown blocks. - */ - static render(text: string): React.ReactNode[] { - if (!text) return []; - - const lines = text.split('\n'); - // Regexes for block elements - const headerRegex = /^ *(#{1,4}) +(.*)/; - const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; // ```lang or ``` or ~~~ - const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; // Unordered list item, captures leading spaces, bullet and text - const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; // Ordered list item, captures leading spaces, number and text - const hrRegex = /^ *([-*_] *){3,} *$/; // Horizontal rule - - const contentBlocks: React.ReactNode[] = []; - // State for parsing across lines - let inCodeBlock = false; - let codeBlockContent: string[] = []; - let codeBlockLang: string | null = null; - let codeBlockFence = ''; // Store the type of fence used (``` or ~~~) - - lines.forEach((line, index) => { - const key = `line-${index}`; - - // --- State 1: Inside a Code Block --- - if (inCodeBlock) { - const fenceMatch = line.match(codeFenceRegex); - // Check for closing fence, matching the opening one and length - if ( - fenceMatch && - fenceMatch[1].startsWith(codeBlockFence[0]) && - fenceMatch[1].length >= codeBlockFence.length - ) { - // End of code block - render it - contentBlocks.push( - MarkdownRenderer._renderCodeBlock( - key, - codeBlockContent, - codeBlockLang, - ), - ); - // Reset state - inCodeBlock = false; - codeBlockContent = []; - codeBlockLang = null; - codeBlockFence = ''; - } else { - // Add line to current code block content - codeBlockContent.push(line); - } - return; // Process next line - } - - // --- State 2: Not Inside a Code Block --- - // Check for block element starts in rough order of precedence/commonness - const codeFenceMatch = line.match(codeFenceRegex); - const headerMatch = line.match(headerRegex); - const ulMatch = line.match(ulItemRegex); - const olMatch = line.match(olItemRegex); - const hrMatch = line.match(hrRegex); - - if (codeFenceMatch) { - inCodeBlock = true; - codeBlockFence = codeFenceMatch[1]; - codeBlockLang = codeFenceMatch[2] || null; - } else if (hrMatch) { - // Render Horizontal Rule (simple dashed line) - // Use box with height and border character, or just Text with dashes - contentBlocks.push( - - --- - , - ); - } else if (headerMatch) { - const level = headerMatch[1].length; - const headerText = headerMatch[2]; - const renderedHeaderText = MarkdownRenderer._renderInline(headerText); - let headerNode: React.ReactNode = null; - switch (level /* ... (header styling as before) ... */) { - case 1: - headerNode = ( - - {renderedHeaderText} - - ); - break; - case 2: - headerNode = ( - - {renderedHeaderText} - - ); - break; - case 3: - headerNode = {renderedHeaderText}; - break; - case 4: - headerNode = ( - - {renderedHeaderText} - - ); - break; - default: - headerNode = {renderedHeaderText}; - break; - } - if (headerNode) contentBlocks.push({headerNode}); - } else if (ulMatch) { - const leadingWhitespace = ulMatch[1]; - const marker = ulMatch[2]; // *, -, or + - const itemText = ulMatch[3]; - // If previous line was not UL, maybe add spacing? For now, just render item. - contentBlocks.push( - MarkdownRenderer._renderListItem( - key, - itemText, - 'ul', - marker, - leadingWhitespace, - ), - ); - } else if (olMatch) { - const leadingWhitespace = olMatch[1]; - const marker = olMatch[2]; // The number - const itemText = olMatch[3]; - contentBlocks.push( - MarkdownRenderer._renderListItem( - key, - itemText, - 'ol', - marker, - leadingWhitespace, - ), - ); - } else { - // --- Regular line (Paragraph or Empty line) --- - // Render line content if it's not blank, applying inline styles - const renderedLine = MarkdownRenderer._renderInline(line); - if (renderedLine.length > 0 || line.length > 0) { - // Render lines with content or only whitespace - contentBlocks.push( - - {renderedLine} - , - ); - } else if (line.trim().length === 0) { - // Handle specifically empty lines - // Add minimal space for blank lines between paragraphs/blocks - if (contentBlocks.length > 0 && !inCodeBlock) { - // Avoid adding multiple blank lines consecutively easily - check if previous was also blank? - // For now, add a minimal spacer for any blank line outside code blocks. - contentBlocks.push(); - } - } - } - }); - - // Handle unclosed code block at the end of the input - if (inCodeBlock) { - contentBlocks.push( - MarkdownRenderer._renderCodeBlock( - `line-eof`, - codeBlockContent, - codeBlockLang, - ), - ); - } - - return contentBlocks; - } -}