/** * @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; isPending: boolean; availableTerminalHeight: number; } // Constants for Markdown parsing and rendering const BOLD_MARKER_LENGTH = 2; // For "**" const ITALIC_MARKER_LENGTH = 1; // For "*" or "_" const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~" const INLINE_CODE_MARKER_LENGTH = 1; // For "`" const UNDERLINE_TAG_START_LENGTH = 3; // For "" const UNDERLINE_TAG_END_LENGTH = 4; // For "" 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 = ({ text, isPending, availableTerminalHeight, }) => { 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( , ); 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]; let headerNode: React.ReactNode = null; switch (level) { case 1: headerNode = ( ); break; case 2: headerNode = ( ); break; case 3: headerNode = ( ); break; case 4: headerNode = ( ); break; default: headerNode = ( ); break; } if (headerNode) contentBlocks.push({headerNode}); } else if (ulMatch) { const leadingWhitespace = ulMatch[1]; const marker = ulMatch[2]; const itemText = ulMatch[3]; contentBlocks.push( , ); } else if (olMatch) { const leadingWhitespace = olMatch[1]; const marker = olMatch[2]; const itemText = olMatch[3]; contentBlocks.push( , ); } else { if (line.trim().length === 0) { if (contentBlocks.length > 0 && !inCodeBlock) { contentBlocks.push(); } } else { contentBlocks.push( , ); } } }); if (inCodeBlock) { contentBlocks.push( , ); } return <>{contentBlocks}; }; // Helper functions (adapted from static methods of MarkdownRenderer) interface RenderInlineProps { text: string; } const RenderInlineInternal: React.FC = ({ text }) => { 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 > BOLD_MARKER_LENGTH * 2 ) { renderedNode = ( {fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)} ); } 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 = ( {fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)} ); } else if ( fullMatch.startsWith('~~') && fullMatch.endsWith('~~') && fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2 ) { renderedNode = ( {fullMatch.slice( STRIKETHROUGH_MARKER_LENGTH, -STRIKETHROUGH_MARKER_LENGTH, )} ); } else if ( fullMatch.startsWith('`') && fullMatch.endsWith('`') && fullMatch.length > INLINE_CODE_MARKER_LENGTH ) { const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); if (codeMatch && codeMatch[2]) { renderedNode = ( {codeMatch[2]} ); } else { renderedNode = ( {fullMatch.slice( INLINE_CODE_MARKER_LENGTH, -INLINE_CODE_MARKER_LENGTH, )} ); } } 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 > UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 // -1 because length is compared to combined length of start and end tags ) { renderedNode = ( {fullMatch.slice( UNDERLINE_TAG_START_LENGTH, -UNDERLINE_TAG_END_LENGTH, )} ); } } 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)}; }; const RenderInline = React.memo(RenderInlineInternal); interface RenderCodeBlockProps { content: string[]; lang: string | null; isPending: boolean; availableTerminalHeight: number; } const RenderCodeBlockInternal: React.FC = ({ content, lang, isPending, availableTerminalHeight, }) => { const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding const MAX_CODE_LINES_WHEN_PENDING = Math.max( 0, availableTerminalHeight - CODE_BLOCK_PADDING * 2 - RESERVED_LINES, ); if (isPending) { if (content.length > MAX_CODE_LINES_WHEN_PENDING) { if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) { // Not enough space to even show the message meaningfully return ( ... code is being written ... ); } const truncatedContent = content.slice(0, MAX_CODE_LINES_WHEN_PENDING); const colorizedTruncatedCode = colorizeCode( truncatedContent.join('\n'), lang, ); return ( {colorizedTruncatedCode} ... generating more ... ); } } const fullContent = content.join('\n'); const colorizedCode = colorizeCode(fullContent, lang); return ( {colorizedCode} ); }; const RenderCodeBlock = React.memo(RenderCodeBlockInternal); interface RenderListItemProps { itemText: string; type: 'ul' | 'ol'; marker: string; leadingWhitespace?: string; } const RenderListItemInternal: React.FC = ({ itemText, type, marker, leadingWhitespace = '', }) => { const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefixWidth = prefix.length; const indentation = leadingWhitespace.length; return ( {prefix} ); }; const RenderListItem = React.memo(RenderListItemInternal); export const MarkdownDisplay = React.memo(MarkdownDisplayInternal);