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