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.
This commit is contained in:
Taylor Mullen 2025-05-15 00:36:08 -07:00 committed by N. Taylor Mullen
parent 59e8fcb409
commit 6cb6f47b56
5 changed files with 307 additions and 377 deletions

View File

@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { Text, Box } from 'ink'; import { Text, Box } from 'ink';
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
interface GeminiMessageProps { interface GeminiMessageProps {
@ -16,7 +16,6 @@ interface GeminiMessageProps {
export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => { export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => {
const prefix = '✦ '; const prefix = '✦ ';
const prefixWidth = prefix.length; const prefixWidth = prefix.length;
const renderedBlocks = MarkdownRenderer.render(text);
return ( return (
<Box flexDirection="row"> <Box flexDirection="row">
@ -24,7 +23,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => {
<Text color={Colors.AccentPurple}>{prefix}</Text> <Text color={Colors.AccentPurple}>{prefix}</Text>
</Box> </Box>
<Box flexGrow={1} flexDirection="column"> <Box flexGrow={1} flexDirection="column">
{renderedBlocks} <MarkdownDisplay text={text} />
</Box> </Box>
</Box> </Box>
); );

View File

@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { Box } from 'ink'; import { Box } from 'ink';
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface GeminiMessageContentProps { interface GeminiMessageContentProps {
text: string; text: string;
@ -23,11 +23,10 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
}) => { }) => {
const originalPrefix = '✦ '; const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length; const prefixWidth = originalPrefix.length;
const renderedBlocks = MarkdownRenderer.render(text);
return ( return (
<Box flexDirection="column" paddingLeft={prefixWidth}> <Box flexDirection="column" paddingLeft={prefixWidth}>
{renderedBlocks} <MarkdownDisplay text={text} />
</Box> </Box>
); );
}; };

View File

@ -10,7 +10,7 @@ import Spinner from 'ink-spinner';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js'; import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import { DiffRenderer } from './DiffRenderer.js'; import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
name, name,
@ -60,7 +60,7 @@ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
{/* Use default text color (white) or gray instead of dimColor */} {/* Use default text color (white) or gray instead of dimColor */}
{typeof resultDisplay === 'string' && ( {typeof resultDisplay === 'string' && (
<Box flexDirection="column"> <Box flexDirection="column">
{MarkdownRenderer.render(resultDisplay)} <MarkdownDisplay text={resultDisplay} />
</Box> </Box>
)} )}
{typeof resultDisplay === 'object' && ( {typeof resultDisplay === 'object' && (

View File

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

View File

@ -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;
}
}