/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { Text, Box } from 'ink'; import { theme } from '../semantic-colors.js'; import { appendCodeBlockLine } from './codeBlockAccumulator.js'; import { colorizeCode } from './CodeColorizer.js'; import { TableRenderer } from './TableRenderer.js'; import { RenderInline } from './InlineMarkdownRenderer.js'; import { useSettings } from '../contexts/SettingsContext.js'; interface MarkdownDisplayProps { text: string; isPending: boolean; availableTerminalHeight?: number; terminalWidth: number; renderMarkdown?: boolean; workspaceDirectories?: readonly string[]; } // Constants for Markdown parsing and rendering const EMPTY_LINE_HEIGHT = 1; const CODE_BLOCK_PREFIX_PADDING = 1; const LIST_ITEM_PREFIX_PADDING = 1; const LIST_ITEM_TEXT_FLEX_GROW = 1; const MarkdownDisplayInternal: React.FC = ({ text, isPending, availableTerminalHeight, terminalWidth, renderMarkdown = true, workspaceDirectories, }) => { const settings = useSettings(); const responseColor = theme.text.response; if (!text) return <>; if (!renderMarkdown) { const colorizedMarkdown = colorizeCode( text, 'markdown', availableTerminalHeight, terminalWidth - CODE_BLOCK_PREFIX_PADDING, undefined, settings, true, ); return ( {colorizedMarkdown} ); } const lines = text.split(/\r?\n/); const regexes = buildMarkdownRegexes(); const { contentBlocks, codeBlockState: finalCodeBlockState, inTable: endedInTable, tableHeaders: finalHeaders, tableRows: finalRows, } = processLines( lines, regexes, isPending, availableTerminalHeight, terminalWidth, responseColor, workspaceDirectories, ); if (finalCodeBlockState.inCodeBlock) { contentBlocks.push( , ); } if (endedInTable && finalHeaders.length > 0 && finalRows.length > 0) { contentBlocks.push( , ); } return <>{contentBlocks}; }; // Helper functions (adapted from static methods of MarkdownRenderer) interface MarkdownRegexes { headerRegex: RegExp; codeFenceRegex: RegExp; ulItemRegex: RegExp; olItemRegex: RegExp; hrRegex: RegExp; tableRowRegex: RegExp; tableSeparatorRegex: RegExp; } // Markdown line patterns. Each is passed to RegExp via an identifier so it is // not a static literal flagged by sonarjs/regular-expr; the code-fence, // horizontal-rule, and table-separator patterns use bounded quantifiers to avoid // sonarjs/slow-regex while remaining behaviourally identical to the originals. const HEADER_PATTERN = '^ *(#{1,4}) +(.*)'; const CODE_FENCE_PATTERN = '^ {0,40}(`{3,100}|~{3,100}) {0,40}(\\w{0,100}?) {0,40}$'; const UL_ITEM_PATTERN = '^([ \\t]*)([-*+]) +(.*)'; const OL_ITEM_PATTERN = '^([ \\t]*)(\\d+)\\. +(.*)'; const HR_PATTERN = '^ *([-*_] {0,40}){3,200} *$'; const TABLE_ROW_PATTERN = '^\\s*\\|(.+)\\|\\s*$'; const TABLE_SEPARATOR_PATTERN = '^\\s{0,40}\\|?\\s{0,40}(:?-{1,200}:?)\\s{0,40}(\\|\\s{0,40}(:?-{1,200}:?)\\s{0,40}){1,200}\\|?\\s{0,40}$'; function buildMarkdownRegexes(): MarkdownRegexes { return { headerRegex: new RegExp(HEADER_PATTERN), codeFenceRegex: new RegExp(CODE_FENCE_PATTERN), ulItemRegex: new RegExp(UL_ITEM_PATTERN), olItemRegex: new RegExp(OL_ITEM_PATTERN), hrRegex: new RegExp(HR_PATTERN), tableRowRegex: new RegExp(TABLE_ROW_PATTERN), tableSeparatorRegex: new RegExp(TABLE_SEPARATOR_PATTERN), }; } interface LineMatchResult { codeFenceMatch: RegExpMatchArray | null; headerMatch: RegExpMatchArray | null; ulMatch: RegExpMatchArray | null; olMatch: RegExpMatchArray | null; hrMatch: RegExpMatchArray | null; tableRowMatch: RegExpMatchArray | null; tableSeparatorMatch: RegExpMatchArray | null; } function matchLine(line: string, regexes: MarkdownRegexes): LineMatchResult { return { codeFenceMatch: line.match(regexes.codeFenceRegex), headerMatch: line.match(regexes.headerRegex), ulMatch: line.match(regexes.ulItemRegex), olMatch: line.match(regexes.olItemRegex), hrMatch: line.match(regexes.hrRegex), tableRowMatch: line.match(regexes.tableRowRegex), tableSeparatorMatch: line.match(regexes.tableSeparatorRegex), }; } interface ProcessLinesResult { contentBlocks: React.ReactNode[]; codeBlockState: CodeBlockState; inTable: boolean; tableHeaders: string[]; tableRows: string[][]; } function handleCodeBlockLine( line: string, key: string, codeBlockFence: string, regexes: MarkdownRegexes, isPending: boolean, availableTerminalHeight: number | undefined, terminalWidth: number, codeBlockContent: string[], codeBlockLang: string | null, addContentBlock: (block: React.ReactNode) => void, ): CodeBlockState { const fenceMatch = line.match(regexes.codeFenceRegex); if ( fenceMatch !== null && fenceMatch[1].startsWith(codeBlockFence[0]) && fenceMatch[1].length >= codeBlockFence.length ) { addContentBlock( , ); return { inCodeBlock: false, codeBlockContent: [], codeBlockLang: null, codeBlockFence: '', }; } appendCodeBlockLine( codeBlockContent, line, isPending, availableTerminalHeight, ); return { inCodeBlock: true, codeBlockContent, codeBlockLang, codeBlockFence, }; } interface CodeBlockState { inCodeBlock: boolean; codeBlockContent: string[]; codeBlockLang: string | null; codeBlockFence: string; } function processLineEntry( line: string, index: number, lines: string[], regexes: MarkdownRegexes, isPending: boolean, availableTerminalHeight: number | undefined, terminalWidth: number, codeBlockState: CodeBlockState, inTable: boolean, tableHeaders: string[], tableRows: string[][], responseColor: string, workspaceDirectories: readonly string[] | undefined, addContentBlock: (block: React.ReactNode) => void, applyLineResult: (result: LineProcessResult, index: number) => void, ): CodeBlockState { if (codeBlockState.inCodeBlock) { return handleCodeBlockLine( line, `line-${index}`, codeBlockState.codeBlockFence, regexes, isPending, availableTerminalHeight, terminalWidth, codeBlockState.codeBlockContent, codeBlockState.codeBlockLang, addContentBlock, ); } const matches = matchLine(line, regexes); if (matches.codeFenceMatch !== null) { return { ...codeBlockState, inCodeBlock: true, codeBlockFence: matches.codeFenceMatch[1], codeBlockLang: matches.codeFenceMatch[2] || null, }; } applyLineResult( processLine( line, `line-${index}`, index, lines, matches, inTable, tableHeaders, tableRows, regexes, responseColor, workspaceDirectories, ), index, ); return codeBlockState; } function processLines( lines: string[], regexes: MarkdownRegexes, isPending: boolean, availableTerminalHeight: number | undefined, terminalWidth: number, responseColor: string, workspaceDirectories: readonly string[] | undefined, ): ProcessLinesResult { const contentBlocks: React.ReactNode[] = []; const renderState = { lastLineEmpty: true }; let inTable = false; let tableRows: string[][] = []; let tableHeaders: string[] = []; function addContentBlock(block: React.ReactNode) { contentBlocks.push(block); renderState.lastLineEmpty = false; } function applyLineResult(result: LineProcessResult, index: number) { if (result.tableFlush && tableHeaders.length > 0 && tableRows.length > 0) { addContentBlock( , ); } inTable = result.inTable; tableHeaders = result.tableHeaders; tableRows = result.tableRows; if (result.block !== null) { addContentBlock(result.block); } else if (result.emptyLine && !renderState.lastLineEmpty) { contentBlocks.push( , ); renderState.lastLineEmpty = true; } } let codeBlockState: CodeBlockState = { inCodeBlock: false, codeBlockContent: [], codeBlockLang: null, codeBlockFence: '', }; for (const [index, line] of lines.entries()) { codeBlockState = processLineEntry( line, index, lines, regexes, isPending, availableTerminalHeight, terminalWidth, codeBlockState, inTable, tableHeaders, tableRows, responseColor, workspaceDirectories, addContentBlock, applyLineResult, ); } return { contentBlocks, codeBlockState, inTable, tableHeaders, tableRows, }; } interface LineProcessResult { block: React.ReactNode | null; emptyLine: boolean; inTable: boolean; tableHeaders: string[]; tableRows: string[][]; tableFlush: boolean; } function renderHeaderNode( headerMatch: RegExpMatchArray, responseColor: string, workspaceDirectories: readonly string[] | undefined, ): React.ReactNode { const level = headerMatch[1].length; const headerText = headerMatch[2]; switch (level) { case 1: case 2: return ( ); case 3: return ( ); case 4: return ( ); default: return ( ); } } function processTableLine( line: string, key: string, matches: LineMatchResult, currentInTable: boolean, currentTableHeaders: string[], currentTableRows: string[][], responseColor: string, workspaceDirectories: readonly string[] | undefined, ): LineProcessResult { const empty: LineProcessResult = { block: null, emptyLine: false, inTable: currentInTable, tableHeaders: currentTableHeaders, tableRows: currentTableRows, tableFlush: false, }; if (matches.tableRowMatch && !currentInTable) { return { ...empty, inTable: true, tableHeaders: matches.tableRowMatch[1] .split('|') .map((cell) => cell.trim()), tableRows: [], }; } if (currentInTable && matches.tableSeparatorMatch) { return empty; } if (currentInTable && matches.tableRowMatch) { const cells = matches.tableRowMatch[1] .split('|') .map((cell) => cell.trim()); while (cells.length < currentTableHeaders.length) { cells.push(''); } if (cells.length > currentTableHeaders.length) { cells.length = currentTableHeaders.length; } return { ...empty, tableRows: [...currentTableRows, cells], }; } if (currentInTable) { const block = line.trim().length > 0 ? ( ) : null; return { ...empty, block, inTable: false, tableHeaders: [], tableRows: [], tableFlush: true, }; } return empty; } function renderListItemBlock( key: string, itemText: string, type: 'ul' | 'ol', marker: string, leadingWhitespace: string, workspaceDirectories: readonly string[] | undefined, ): React.ReactNode { return ( ); } function renderHrBlock(key: string): React.ReactNode { return ( --- ); } function renderHeaderBlock( key: string, headerMatch: RegExpMatchArray, responseColor: string, workspaceDirectories: readonly string[] | undefined, ): React.ReactNode { return ( {renderHeaderNode(headerMatch, responseColor, workspaceDirectories)} ); } function renderParagraphBlock( key: string, line: string, responseColor: string, workspaceDirectories: readonly string[] | undefined, ): React.ReactNode { return ( ); } function processNonTableLine( line: string, key: string, matches: LineMatchResult, responseColor: string, workspaceDirectories: readonly string[] | undefined, ): LineProcessResult { const empty: LineProcessResult = { block: null, emptyLine: false, inTable: false, tableHeaders: [], tableRows: [], tableFlush: false, }; if (matches.hrMatch) { return { ...empty, block: renderHrBlock(key) }; } if (matches.headerMatch) { return { ...empty, block: renderHeaderBlock( key, matches.headerMatch, responseColor, workspaceDirectories, ), }; } if (matches.ulMatch) { return { ...empty, block: renderListItemBlock( key, matches.ulMatch[3], 'ul', matches.ulMatch[2], matches.ulMatch[1], workspaceDirectories, ), }; } if (matches.olMatch) { return { ...empty, block: renderListItemBlock( key, matches.olMatch[3], 'ol', matches.olMatch[2], matches.olMatch[1], workspaceDirectories, ), }; } if (line.trim().length === 0) { return { ...empty, emptyLine: true }; } return { ...empty, block: renderParagraphBlock(key, line, responseColor, workspaceDirectories), }; } function processLine( line: string, key: string, index: number, lines: string[], matches: LineMatchResult, currentInTable: boolean, currentTableHeaders: string[], currentTableRows: string[][], regexes: MarkdownRegexes, responseColor: string, workspaceDirectories: readonly string[] | undefined, ): LineProcessResult { if (matches.tableRowMatch && !currentInTable) { if ( index + 1 < lines.length && lines[index + 1].match(regexes.tableSeparatorRegex) ) { return processTableLine( line, key, matches, currentInTable, currentTableHeaders, currentTableRows, responseColor, workspaceDirectories, ); } return { block: ( ), emptyLine: false, inTable: false, tableHeaders: currentTableHeaders, tableRows: currentTableRows, tableFlush: false, }; } if (currentInTable) { return processTableLine( line, key, matches, currentInTable, currentTableHeaders, currentTableRows, responseColor, workspaceDirectories, ); } return processNonTableLine( line, key, matches, responseColor, workspaceDirectories, ); } interface RenderCodeBlockProps { content: string[]; lang: string | null; isPending: boolean; availableTerminalHeight?: number; terminalWidth: number; } const RenderCodeBlockInternal: React.FC = ({ content, lang, isPending, availableTerminalHeight, terminalWidth, }) => { const settings = useSettings(); 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 if (isPending && availableTerminalHeight !== undefined) { const MAX_CODE_LINES_WHEN_PENDING = Math.max( 0, availableTerminalHeight - RESERVED_LINES, ); 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, availableTerminalHeight, terminalWidth - CODE_BLOCK_PREFIX_PADDING, undefined, settings, ); return ( {colorizedTruncatedCode} ... generating more ... ); } } const fullContent = content.join('\n'); const colorizedCode = colorizeCode( fullContent, lang, availableTerminalHeight, terminalWidth - CODE_BLOCK_PREFIX_PADDING, undefined, settings, ); return ( {colorizedCode} ); }; const RenderCodeBlock = React.memo(RenderCodeBlockInternal); interface RenderListItemProps { itemText: string; type: 'ul' | 'ol'; marker: string; leadingWhitespace?: string; workspaceDirectories?: readonly string[]; } const RenderListItemInternal: React.FC = ({ itemText, type, marker, leadingWhitespace = '', workspaceDirectories, }) => { const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefixWidth = prefix.length; const indentation = leadingWhitespace.length; const listResponseColor = theme.text.response; return ( {prefix} ); }; const RenderListItem = React.memo(RenderListItemInternal); interface RenderTableProps { headers: string[]; rows: string[][]; terminalWidth: number; } const RenderTableInternal: React.FC = ({ headers, rows, terminalWidth, }) => ( ); const RenderTable = React.memo(RenderTableInternal); export const MarkdownDisplay = React.memo(MarkdownDisplayInternal);