diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index b3e88d9a01..29a857f3e0 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { Text, Box } from 'ink'; import { theme } from '../semantic-colors.js'; import { colorizeCode } from './CodeColorizer.js'; @@ -28,6 +28,14 @@ const CODE_BLOCK_PREFIX_PADDING = 1; const LIST_ITEM_PREFIX_PADDING = 1; const LIST_ITEM_TEXT_FLEX_GROW = 1; +const headerRegex = /^ *(#{1,4}) +(.*)/; +const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/; +const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; +const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; +const hrRegex = /^ *([-*_] *){3,} *$/; +const tableRowRegex = /^\s*\|(.+)\|\s*$/; +const tableSeparatorRegex = /^\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)+\|?\s*$/; + const MarkdownDisplayInternal: React.FC = ({ text, isPending, @@ -39,282 +47,284 @@ const MarkdownDisplayInternal: React.FC = ({ const isAlternateBuffer = useAlternateBuffer(); const responseColor = theme.text.response ?? theme.text.primary; - if (!text) return <>; + const contentBlocks = useMemo(() => { + if (!text) return []; - // Raw markdown mode - display syntax-highlighted markdown without rendering - if (!renderMarkdown) { - // Hide line numbers in raw markdown mode as they are confusing due to chunked output - const colorizedMarkdown = colorizeCode({ - code: text, - language: 'markdown', - availableHeight: isAlternateBuffer ? undefined : availableTerminalHeight, - maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING, - settings, - hideLineNumbers: true, - }); - return ( - - {colorizedMarkdown} - - ); - } - - const lines = text.split(/\r?\n/); - const headerRegex = /^ *(#{1,4}) +(.*)/; - const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/; - const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; - const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; - const hrRegex = /^ *([-*_] *){3,} *$/; - const tableRowRegex = /^\s*\|(.+)\|\s*$/; - const tableSeparatorRegex = /^\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)+\|?\s*$/; - - const contentBlocks: React.ReactNode[] = []; - let inCodeBlock = false; - let lastLineEmpty = true; - let codeBlockContent: string[] = []; - let codeBlockLang: string | null = null; - let codeBlockFence = ''; - let inTable = false; - let tableRows: string[][] = []; - let tableHeaders: string[] = []; - - function addContentBlock(block: React.ReactNode) { - if (block) { - contentBlocks.push(block); - lastLineEmpty = false; + // Raw markdown mode - display syntax-highlighted markdown without rendering + if (!renderMarkdown) { + const colorizedMarkdown = colorizeCode({ + code: text, + language: 'markdown', + availableHeight: isAlternateBuffer + ? undefined + : availableTerminalHeight, + maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING, + settings, + hideLineNumbers: true, + }); + return [ + + {colorizedMarkdown} + , + ]; } - } - lines.forEach((line, index) => { - const key = `line-${index}`; + const lines = text.split(/\r?\n/); + const blocks: React.ReactNode[] = []; + let inCodeBlock = false; + let lastLineEmpty = true; + let codeBlockContent: string[] = []; + let codeBlockLang: string | null = null; + let codeBlockFence = ''; + let inTable = false; + let tableRows: string[][] = []; + let tableHeaders: string[] = []; + + function addBlock(block: React.ReactNode) { + if (block) { + blocks.push(block); + lastLineEmpty = false; + } + } + + 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 + ) { + addBlock( + , + ); + 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); + const tableRowMatch = line.match(tableRowRegex); + const tableSeparatorMatch = line.match(tableSeparatorRegex); + + if (codeFenceMatch) { + inCodeBlock = true; + codeBlockFence = codeFenceMatch[1]; + codeBlockLang = codeFenceMatch[2] || null; + } else if (tableRowMatch && !inTable) { + if ( + index + 1 < lines.length && + lines[index + 1].match(tableSeparatorRegex) + ) { + inTable = true; + tableHeaders = tableRowMatch[1].split('|').map((cell) => cell.trim()); + tableRows = []; + } else { + addBlock( + + + + + , + ); + } + } else if (inTable && tableSeparatorMatch) { + // Skip separator + } else if (inTable && tableRowMatch) { + const cells = tableRowMatch[1].split('|').map((cell) => cell.trim()); + while (cells.length < tableHeaders.length) { + cells.push(''); + } + if (cells.length > tableHeaders.length) { + cells.length = tableHeaders.length; + } + tableRows.push(cells); + } else if (inTable && !tableRowMatch) { + if (tableHeaders.length > 0 && tableRows.length > 0) { + addBlock( + , + ); + } + inTable = false; + tableRows = []; + tableHeaders = []; + + if (line.trim().length > 0) { + addBlock( + + + + + , + ); + } + } else if (hrMatch) { + addBlock( + + --- + , + ); + } else if (headerMatch) { + const level = headerMatch[1].length; + const headerText = headerMatch[2]; + let headerNode: React.ReactNode = null; + switch (level) { + case 1: + case 2: + headerNode = ( + + + + ); + break; + case 3: + headerNode = ( + + + + ); + break; + case 4: + headerNode = ( + + + + ); + break; + default: + headerNode = ( + + + + ); + break; + } + if (headerNode) addBlock({headerNode}); + } else if (ulMatch) { + const leadingWhitespace = ulMatch[1]; + const marker = ulMatch[2]; + const itemText = ulMatch[3]; + addBlock( + , + ); + } else if (olMatch) { + const leadingWhitespace = olMatch[1]; + const marker = olMatch[2]; + const itemText = olMatch[3]; + addBlock( + , + ); + } else { + if (line.trim().length === 0 && !inCodeBlock) { + if (!lastLineEmpty) { + blocks.push( + , + ); + lastLineEmpty = true; + } + } else { + addBlock( + + + + + , + ); + } + } + }); if (inCodeBlock) { - const fenceMatch = line.match(codeFenceRegex); - if ( - fenceMatch && - fenceMatch[1].startsWith(codeBlockFence[0]) && - fenceMatch[1].length >= codeBlockFence.length - ) { - addContentBlock( - , - ); - 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); - const tableRowMatch = line.match(tableRowRegex); - const tableSeparatorMatch = line.match(tableSeparatorRegex); - - if (codeFenceMatch) { - inCodeBlock = true; - codeBlockFence = codeFenceMatch[1]; - codeBlockLang = codeFenceMatch[2] || null; - } else if (tableRowMatch && !inTable) { - // Potential table start - check if next line is separator - if ( - index + 1 < lines.length && - lines[index + 1].match(tableSeparatorRegex) - ) { - inTable = true; - tableHeaders = tableRowMatch[1].split('|').map((cell) => cell.trim()); - tableRows = []; - } else { - // Not a table, treat as regular text - addContentBlock( - - - - - , - ); - } - } else if (inTable && tableSeparatorMatch) { - // Skip separator line - already handled - } else if (inTable && tableRowMatch) { - // Add table row - const cells = tableRowMatch[1].split('|').map((cell) => cell.trim()); - // Ensure row has same column count as headers - while (cells.length < tableHeaders.length) { - cells.push(''); - } - if (cells.length > tableHeaders.length) { - cells.length = tableHeaders.length; - } - tableRows.push(cells); - } else if (inTable && !tableRowMatch) { - // End of table - if (tableHeaders.length > 0 && tableRows.length > 0) { - addContentBlock( - , - ); - } - inTable = false; - tableRows = []; - tableHeaders = []; - - // Process current line as normal - if (line.trim().length > 0) { - addContentBlock( - - - - - , - ); - } - } else if (hrMatch) { - addContentBlock( - - --- - , - ); - } 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) addContentBlock({headerNode}); - } else if (ulMatch) { - const leadingWhitespace = ulMatch[1]; - const marker = ulMatch[2]; - const itemText = ulMatch[3]; - addContentBlock( - , ); - } else if (olMatch) { - const leadingWhitespace = olMatch[1]; - const marker = olMatch[2]; - const itemText = olMatch[3]; - addContentBlock( - 0 && tableRows.length > 0) { + addBlock( + , ); - } else { - if (line.trim().length === 0 && !inCodeBlock) { - if (!lastLineEmpty) { - contentBlocks.push( - , - ); - lastLineEmpty = true; - } - } else { - addContentBlock( - - - - - , - ); - } } - }); - if (inCodeBlock) { - addContentBlock( - , - ); - } + return blocks; + }, [ + text, + isPending, + availableTerminalHeight, + terminalWidth, + renderMarkdown, + settings, + isAlternateBuffer, + responseColor, + ]); - // Handle table at end of content - if (inTable && tableHeaders.length > 0 && tableRows.length > 0) { - addContentBlock( - , - ); - } + if (!text) return <>; return <>{contentBlocks}; }; -// Helper functions (adapted from static methods of MarkdownRenderer) +// Helper components interface RenderCodeBlockProps { content: string[]; @@ -333,11 +343,9 @@ const RenderCodeBlockInternal: React.FC = ({ }) => { const settings = useSettings(); const isAlternateBuffer = useAlternateBuffer(); - 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 MIN_LINES_FOR_MESSAGE = 1; + const RESERVED_LINES = 2; - // When not in alternate buffer mode we need to be careful that we don't - // trigger flicker when the pending code is too long to fit in the terminal if ( !isAlternateBuffer && isPending && @@ -350,7 +358,6 @@ const RenderCodeBlockInternal: React.FC = ({ 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 ( @@ -414,7 +421,6 @@ const RenderListItemInternal: React.FC = ({ }) => { const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefixWidth = prefix.length; - // Account for leading whitespace (indentation level) plus the standard prefix padding const indentation = leadingWhitespace.length; const listResponseColor = theme.text.response ?? theme.text.primary;