|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import React from 'react'; |
|
|
import { Text, Box } from 'ink'; |
|
|
import { Colors } from '../colors.js'; |
|
|
import { colorizeCode } from './CodeColorizer.js'; |
|
|
|
|
|
interface MarkdownDisplayProps { |
|
|
text: string; |
|
|
isPending: boolean; |
|
|
availableTerminalHeight?: number; |
|
|
terminalWidth: number; |
|
|
} |
|
|
|
|
|
|
|
|
const BOLD_MARKER_LENGTH = 2; |
|
|
const ITALIC_MARKER_LENGTH = 1; |
|
|
const STRIKETHROUGH_MARKER_LENGTH = 2; |
|
|
const INLINE_CODE_MARKER_LENGTH = 1; |
|
|
const UNDERLINE_TAG_START_LENGTH = 3; |
|
|
const UNDERLINE_TAG_END_LENGTH = 4; |
|
|
|
|
|
const EMPTY_LINE_HEIGHT = 1; |
|
|
const CODE_BLOCK_PADDING = 1; |
|
|
const LIST_ITEM_PREFIX_PADDING = 1; |
|
|
const LIST_ITEM_TEXT_FLEX_GROW = 1; |
|
|
|
|
|
const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({ |
|
|
text, |
|
|
isPending, |
|
|
availableTerminalHeight, |
|
|
terminalWidth, |
|
|
}) => { |
|
|
if (!text) return <></>; |
|
|
|
|
|
const lines = text.split('\n'); |
|
|
const headerRegex = /^ *(#{1,4}) +(.*)/; |
|
|
const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/; |
|
|
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={key} |
|
|
content={codeBlockContent} |
|
|
lang={codeBlockLang} |
|
|
isPending={isPending} |
|
|
availableTerminalHeight={availableTerminalHeight} |
|
|
terminalWidth={terminalWidth} |
|
|
/>, |
|
|
); |
|
|
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]; |
|
|
let headerNode: React.ReactNode = null; |
|
|
switch (level) { |
|
|
case 1: |
|
|
headerNode = ( |
|
|
<Text bold color={Colors.AccentCyan}> |
|
|
<RenderInline text={headerText} /> |
|
|
</Text> |
|
|
); |
|
|
break; |
|
|
case 2: |
|
|
headerNode = ( |
|
|
<Text bold color={Colors.AccentBlue}> |
|
|
<RenderInline text={headerText} /> |
|
|
</Text> |
|
|
); |
|
|
break; |
|
|
case 3: |
|
|
headerNode = ( |
|
|
<Text bold> |
|
|
<RenderInline text={headerText} /> |
|
|
</Text> |
|
|
); |
|
|
break; |
|
|
case 4: |
|
|
headerNode = ( |
|
|
<Text italic color={Colors.Gray}> |
|
|
<RenderInline text={headerText} /> |
|
|
</Text> |
|
|
); |
|
|
break; |
|
|
default: |
|
|
headerNode = ( |
|
|
<Text> |
|
|
<RenderInline text={headerText} /> |
|
|
</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={key} |
|
|
itemText={itemText} |
|
|
type="ul" |
|
|
marker={marker} |
|
|
leadingWhitespace={leadingWhitespace} |
|
|
/>, |
|
|
); |
|
|
} else if (olMatch) { |
|
|
const leadingWhitespace = olMatch[1]; |
|
|
const marker = olMatch[2]; |
|
|
const itemText = olMatch[3]; |
|
|
contentBlocks.push( |
|
|
<RenderListItem |
|
|
key={key} |
|
|
itemText={itemText} |
|
|
type="ol" |
|
|
marker={marker} |
|
|
leadingWhitespace={leadingWhitespace} |
|
|
/>, |
|
|
); |
|
|
} else { |
|
|
if (line.trim().length === 0) { |
|
|
if (contentBlocks.length > 0 && !inCodeBlock) { |
|
|
contentBlocks.push(<Box key={key} height={EMPTY_LINE_HEIGHT} />); |
|
|
} |
|
|
} else { |
|
|
contentBlocks.push( |
|
|
<Box key={key}> |
|
|
<Text wrap="wrap"> |
|
|
<RenderInline text={line} /> |
|
|
</Text> |
|
|
</Box>, |
|
|
); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
if (inCodeBlock) { |
|
|
contentBlocks.push( |
|
|
<RenderCodeBlock |
|
|
key="line-eof" |
|
|
content={codeBlockContent} |
|
|
lang={codeBlockLang} |
|
|
isPending={isPending} |
|
|
availableTerminalHeight={availableTerminalHeight} |
|
|
terminalWidth={terminalWidth} |
|
|
/>, |
|
|
); |
|
|
} |
|
|
|
|
|
return <>{contentBlocks}</>; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
interface RenderInlineProps { |
|
|
text: string; |
|
|
} |
|
|
|
|
|
const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => { |
|
|
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 > BOLD_MARKER_LENGTH * 2 |
|
|
) { |
|
|
renderedNode = ( |
|
|
<Text key={key} bold> |
|
|
{fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)} |
|
|
</Text> |
|
|
); |
|
|
} else if ( |
|
|
fullMatch.length > ITALIC_MARKER_LENGTH * 2 && |
|
|
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || |
|
|
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && |
|
|
!/\w/.test(text.substring(match.index - 1, match.index)) && |
|
|
!/\w/.test( |
|
|
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1), |
|
|
) && |
|
|
!/\S[./\\]/.test(text.substring(match.index - 2, match.index)) && |
|
|
!/[./\\]\S/.test( |
|
|
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2), |
|
|
) |
|
|
) { |
|
|
renderedNode = ( |
|
|
<Text key={key} italic> |
|
|
{fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)} |
|
|
</Text> |
|
|
); |
|
|
} else if ( |
|
|
fullMatch.startsWith('~~') && |
|
|
fullMatch.endsWith('~~') && |
|
|
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2 |
|
|
) { |
|
|
renderedNode = ( |
|
|
<Text key={key} strikethrough> |
|
|
{fullMatch.slice( |
|
|
STRIKETHROUGH_MARKER_LENGTH, |
|
|
-STRIKETHROUGH_MARKER_LENGTH, |
|
|
)} |
|
|
</Text> |
|
|
); |
|
|
} else if ( |
|
|
fullMatch.startsWith('`') && |
|
|
fullMatch.endsWith('`') && |
|
|
fullMatch.length > INLINE_CODE_MARKER_LENGTH |
|
|
) { |
|
|
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( |
|
|
INLINE_CODE_MARKER_LENGTH, |
|
|
-INLINE_CODE_MARKER_LENGTH, |
|
|
)} |
|
|
</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 > |
|
|
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 |
|
|
) { |
|
|
renderedNode = ( |
|
|
<Text key={key} underline> |
|
|
{fullMatch.slice( |
|
|
UNDERLINE_TAG_START_LENGTH, |
|
|
-UNDERLINE_TAG_END_LENGTH, |
|
|
)} |
|
|
</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)}</>; |
|
|
}; |
|
|
|
|
|
const RenderInline = React.memo(RenderInlineInternal); |
|
|
|
|
|
interface RenderCodeBlockProps { |
|
|
content: string[]; |
|
|
lang: string | null; |
|
|
isPending: boolean; |
|
|
availableTerminalHeight?: number; |
|
|
terminalWidth: number; |
|
|
} |
|
|
|
|
|
const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({ |
|
|
content, |
|
|
lang, |
|
|
isPending, |
|
|
availableTerminalHeight, |
|
|
terminalWidth, |
|
|
}) => { |
|
|
const MIN_LINES_FOR_MESSAGE = 1; |
|
|
const RESERVED_LINES = 2; |
|
|
|
|
|
if (isPending && availableTerminalHeight !== undefined) { |
|
|
const MAX_CODE_LINES_WHEN_PENDING = Math.max( |
|
|
0, |
|
|
availableTerminalHeight - CODE_BLOCK_PADDING * 2 - RESERVED_LINES, |
|
|
); |
|
|
|
|
|
if (content.length > MAX_CODE_LINES_WHEN_PENDING) { |
|
|
if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) { |
|
|
|
|
|
return ( |
|
|
<Box padding={CODE_BLOCK_PADDING}> |
|
|
<Text color={Colors.Gray}>... code is being written ...</Text> |
|
|
</Box> |
|
|
); |
|
|
} |
|
|
const truncatedContent = content.slice(0, MAX_CODE_LINES_WHEN_PENDING); |
|
|
const colorizedTruncatedCode = colorizeCode( |
|
|
truncatedContent.join('\n'), |
|
|
lang, |
|
|
availableTerminalHeight, |
|
|
terminalWidth - CODE_BLOCK_PADDING * 2, |
|
|
); |
|
|
return ( |
|
|
<Box flexDirection="column" padding={CODE_BLOCK_PADDING}> |
|
|
{colorizedTruncatedCode} |
|
|
<Text color={Colors.Gray}>... generating more ...</Text> |
|
|
</Box> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
const fullContent = content.join('\n'); |
|
|
const colorizedCode = colorizeCode( |
|
|
fullContent, |
|
|
lang, |
|
|
availableTerminalHeight, |
|
|
terminalWidth - CODE_BLOCK_PADDING * 2, |
|
|
); |
|
|
|
|
|
return ( |
|
|
<Box |
|
|
flexDirection="column" |
|
|
padding={CODE_BLOCK_PADDING} |
|
|
width={terminalWidth} |
|
|
flexShrink={0} |
|
|
> |
|
|
{colorizedCode} |
|
|
</Box> |
|
|
); |
|
|
}; |
|
|
|
|
|
const RenderCodeBlock = React.memo(RenderCodeBlockInternal); |
|
|
|
|
|
interface RenderListItemProps { |
|
|
itemText: string; |
|
|
type: 'ul' | 'ol'; |
|
|
marker: string; |
|
|
leadingWhitespace?: string; |
|
|
} |
|
|
|
|
|
const RenderListItemInternal: React.FC<RenderListItemProps> = ({ |
|
|
itemText, |
|
|
type, |
|
|
marker, |
|
|
leadingWhitespace = '', |
|
|
}) => { |
|
|
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; |
|
|
const prefixWidth = prefix.length; |
|
|
const indentation = leadingWhitespace.length; |
|
|
|
|
|
return ( |
|
|
<Box |
|
|
paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING} |
|
|
flexDirection="row" |
|
|
> |
|
|
<Box width={prefixWidth}> |
|
|
<Text>{prefix}</Text> |
|
|
</Box> |
|
|
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}> |
|
|
<Text wrap="wrap"> |
|
|
<RenderInline text={itemText} /> |
|
|
</Text> |
|
|
</Box> |
|
|
</Box> |
|
|
); |
|
|
}; |
|
|
|
|
|
const RenderListItem = React.memo(RenderListItemInternal); |
|
|
|
|
|
export const MarkdownDisplay = React.memo(MarkdownDisplayInternal); |
|
|
|