diff options
| author | Taylor Mullen <[email protected]> | 2025-05-15 00:36:08 -0700 |
|---|---|---|
| committer | N. Taylor Mullen <[email protected]> | 2025-05-15 21:57:10 -0700 |
| commit | 6cb6f47b56154220a1adc03984e42813e0cb5dc1 (patch) | |
| tree | 441af16a3bf196f2ee95f96716d267cf15c9d04b | |
| parent | 59e8fcb4096b2f4f3841150035aa995aaebc0e97 (diff) | |
Refactor: Replace MarkdownRenderer with MarkdownDisplay component
- This commit refactors the Markdown rendering logic within the CLI UI.
The existing `MarkdownRenderer.tsx` class-based component has been
replaced with a new functional component `MarkdownDisplay.tsx`.
- The `MarkdownDisplay` component is a React.memoized component for
improved performance and maintains the same core Markdown parsing
and rendering capabilities.
5 files changed, 307 insertions, 377 deletions
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<GeminiMessageProps> = ({ text }) => { const prefix = '✦ '; const prefixWidth = prefix.length; - const renderedBlocks = MarkdownRenderer.render(text); return ( <Box flexDirection="row"> @@ -24,7 +23,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => { <Text color={Colors.AccentPurple}>{prefix}</Text> </Box> <Box flexGrow={1} flexDirection="column"> - {renderedBlocks} + <MarkdownDisplay text={text} /> </Box> </Box> ); 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<GeminiMessageContentProps> = ({ }) => { const originalPrefix = '✦ '; const prefixWidth = originalPrefix.length; - const renderedBlocks = MarkdownRenderer.render(text); return ( <Box flexDirection="column" paddingLeft={prefixWidth}> - {renderedBlocks} + <MarkdownDisplay text={text} /> </Box> ); }; 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<IndividualToolCallDisplay> = ({ name, @@ -60,7 +60,7 @@ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({ {/* Use default text color (white) or gray instead of dimColor */} {typeof resultDisplay === 'string' && ( <Box flexDirection="column"> - {MarkdownRenderer.render(resultDisplay)} + <MarkdownDisplay text={resultDisplay} /> </Box> )} {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( + <Box key={key}> + <Text dimColor>---</Text> + </Box>, + ); + } 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 = ( + <Text bold color={Colors.AccentCyan}> + {renderedHeaderText} + </Text> + ); + break; + case 2: + headerNode = ( + <Text bold color={Colors.AccentBlue}> + {renderedHeaderText} + </Text> + ); + break; + case 3: + headerNode = <Text bold>{renderedHeaderText}</Text>; + break; + case 4: + headerNode = ( + <Text italic color={Colors.SubtleComment}> + {renderedHeaderText} + </Text> + ); + break; + default: + headerNode = <Text>{renderedHeaderText}</Text>; + break; + } + if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>); + } 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( + <Box key={key}> + <Text wrap="wrap">{renderedLine}</Text> + </Box>, + ); + } else if (line.trim().length === 0) { + if (contentBlocks.length > 0 && !inCodeBlock) { + contentBlocks.push(<Box key={key} height={1} />); + } + } + } + }); + + 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>.*?<\/u>)/g; + let match; + + while ((match = inlineRegex.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push( + <Text key={`t-${lastIndex}`}> + {text.slice(lastIndex, match.index)} + </Text>, + ); + } + + const fullMatch = match[0]; + let renderedNode: React.ReactNode = null; + const key = `m-${match.index}`; + + try { + if ( + fullMatch.startsWith('**') && + fullMatch.endsWith('**') && + fullMatch.length > 4 + ) { + renderedNode = ( + <Text key={key} bold> + {fullMatch.slice(2, -2)} + </Text> + ); + } else if ( + ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || + (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && + fullMatch.length > 2 + ) { + renderedNode = ( + <Text key={key} italic> + {fullMatch.slice(1, -1)} + </Text> + ); + } else if ( + fullMatch.startsWith('~~') && + fullMatch.endsWith('~~') && + fullMatch.length > 4 + ) { + renderedNode = ( + <Text key={key} strikethrough> + {fullMatch.slice(2, -2)} + </Text> + ); + } else if ( + fullMatch.startsWith('`') && + fullMatch.endsWith('`') && + fullMatch.length > 1 + ) { + const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); + if (codeMatch && codeMatch[2]) { + renderedNode = ( + <Text key={key} color={Colors.AccentPurple}> + {codeMatch[2]} + </Text> + ); + } else { + renderedNode = ( + <Text key={key} color={Colors.AccentPurple}> + {fullMatch.slice(1, -1)} + </Text> + ); + } + } else if ( + fullMatch.startsWith('[') && + fullMatch.includes('](') && + fullMatch.endsWith(')') + ) { + const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); + if (linkMatch) { + const linkText = linkMatch[1]; + const url = linkMatch[2]; + renderedNode = ( + <Text key={key}> + {linkText} + <Text color={Colors.AccentBlue}> ({url})</Text> + </Text> + ); + } + } else if ( + fullMatch.startsWith('<u>') && + fullMatch.endsWith('</u>') && + fullMatch.length > 6 + ) { + renderedNode = ( + <Text key={key} underline> + {fullMatch.slice(3, -4)} + </Text> + ); + } + } catch (e) { + console.error('Error parsing inline markdown part:', fullMatch, e); + renderedNode = null; + } + + nodes.push(renderedNode ?? <Text key={key}>{fullMatch}</Text>); + lastIndex = inlineRegex.lastIndex; + } + + if (lastIndex < text.length) { + nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>); + } + + 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 ( + <Box key={key} flexDirection="column" padding={1}> + {colorizedCode} + </Box> + ); +} + +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 ( + <Box key={key} paddingLeft={indentation + 1} flexDirection="row"> + <Box width={prefixWidth}> + <Text>{prefix}</Text> + </Box> + <Box flexGrow={1}> + <Text wrap="wrap">{renderedText}</Text> + </Box> + </Box> + ); +} + +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``, <u>underline</u> - * @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>.*?<\/u>)/g; - let match; - - while ((match = inlineRegex.exec(text)) !== null) { - // 1. Add plain text before the match - if (match.index > lastIndex) { - nodes.push( - <Text key={`t-${lastIndex}`}> - {text.slice(lastIndex, match.index)} - </Text>, - ); - } - - 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 = ( - <Text key={key} bold> - {fullMatch.slice(2, -2)} - </Text> - ); - } else if ( - ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || - (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && - fullMatch.length > 2 - ) { - renderedNode = ( - <Text key={key} italic> - {fullMatch.slice(1, -1)} - </Text> - ); - } else if ( - fullMatch.startsWith('~~') && - fullMatch.endsWith('~~') && - fullMatch.length > 4 - ) { - // Strikethrough as gray text - renderedNode = ( - <Text key={key} strikethrough> - {fullMatch.slice(2, -2)} - </Text> - ); - } 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 = ( - <Text key={key} color={Colors.AccentPurple}> - {codeMatch[2]} - </Text> - ); - } else { - // Fallback for simple or non-matching cases - renderedNode = ( - <Text key={key} color={Colors.AccentPurple}> - {fullMatch.slice(1, -1)} - </Text> - ); - } - } 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 = ( - <Text key={key}> - {linkText} - <Text color={Colors.AccentBlue}> ({url})</Text> - </Text> - ); - } - } else if ( - fullMatch.startsWith('<u>') && - fullMatch.endsWith('</u>') && - fullMatch.length > 6 - ) { - // ***** NEW: Handle underline tag ***** - // Use slice(3, -4) to remove <u> and </u> - renderedNode = ( - <Text key={key} underline> - {fullMatch.slice(3, -4)} - </Text> - ); - } - } 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 ?? <Text key={key}>{fullMatch}</Text>); - 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 key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>); - } - - // 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 ( - <Box key={key} flexDirection="column" padding={1}> - {colorizedCode} - </Box> - ); - } - - /** - * 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 ( - <Box key={key} paddingLeft={indentation + 1} flexDirection="row"> - <Box width={prefixWidth}> - <Text>{prefix}</Text> - </Box> - <Box flexGrow={1}> - <Text wrap="wrap">{renderedText}</Text> - </Box> - </Box> - ); - } - - /** - * 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( - <Box key={key}> - <Text dimColor>---</Text> - </Box>, - ); - } 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 = ( - <Text bold color={Colors.AccentCyan}> - {renderedHeaderText} - </Text> - ); - break; - case 2: - headerNode = ( - <Text bold color={Colors.AccentBlue}> - {renderedHeaderText} - </Text> - ); - break; - case 3: - headerNode = <Text bold>{renderedHeaderText}</Text>; - break; - case 4: - headerNode = ( - <Text italic color={Colors.SubtleComment}> - {renderedHeaderText} - </Text> - ); - break; - default: - headerNode = <Text>{renderedHeaderText}</Text>; - break; - } - if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>); - } 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( - <Box key={key}> - <Text wrap="wrap">{renderedLine}</Text> - </Box>, - ); - } 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(<Box key={key} height={1} />); - } - } - } - }); - - // Handle unclosed code block at the end of the input - if (inCodeBlock) { - contentBlocks.push( - MarkdownRenderer._renderCodeBlock( - `line-eof`, - codeBlockContent, - codeBlockLang, - ), - ); - } - - return contentBlocks; - } -} |
