diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 02a34842f4..19d4b3cac8 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -6,223 +6,14 @@ import React from 'react'; import { Text } from 'ink'; -import chalk from 'chalk'; -import { - resolveColor, - INK_SUPPORTED_NAMES, - INK_NAME_TO_HEX_MAP, -} from '../themes/color-utils.js'; -import { theme } from '../semantic-colors.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { parseMarkdownToANSI } from './markdownParsingUtils.js'; import { stripUnsafeCharacters } from './textUtils.js'; -// Constants for Markdown parsing -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 "" - interface RenderInlineProps { text: string; defaultColor?: string; } -/** - * Helper to apply color to a string using ANSI escape codes, - * consistent with how Ink's colorize works. - */ -const ansiColorize = (str: string, color: string | undefined): string => { - if (!color) return str; - const resolved = resolveColor(color); - if (!resolved) return str; - - if (resolved.startsWith('#')) { - return chalk.hex(resolved)(str); - } - - const mappedHex = INK_NAME_TO_HEX_MAP[resolved]; - if (mappedHex) { - return chalk.hex(mappedHex)(str); - } - - if (INK_SUPPORTED_NAMES.has(resolved)) { - switch (resolved) { - case 'black': - return chalk.black(str); - case 'red': - return chalk.red(str); - case 'green': - return chalk.green(str); - case 'yellow': - return chalk.yellow(str); - case 'blue': - return chalk.blue(str); - case 'magenta': - return chalk.magenta(str); - case 'cyan': - return chalk.cyan(str); - case 'white': - return chalk.white(str); - case 'gray': - case 'grey': - return chalk.gray(str); - default: - return str; - } - } - - return str; -}; - -/** - * Converts markdown text into a string with ANSI escape codes. - * This mirrors the parsing logic in InlineMarkdownRenderer.tsx - */ -export const parseMarkdownToANSI = ( - text: string, - defaultColor?: string, -): string => { - const baseColor = defaultColor ?? theme.text.primary; - // Early return for plain text without markdown or URLs - if (!/[*_~`<[https?:]/.test(text)) { - return ansiColorize(text, baseColor); - } - - let result = ''; - const inlineRegex = - /(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; - let lastIndex = 0; - let match; - - while ((match = inlineRegex.exec(text)) !== null) { - if (match.index > lastIndex) { - result += ansiColorize(text.slice(lastIndex, match.index), baseColor); - } - - const fullMatch = match[0]; - let styledPart = ''; - - try { - if ( - fullMatch.endsWith('***') && - fullMatch.startsWith('***') && - fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2 - ) { - styledPart = chalk.bold( - chalk.italic( - parseMarkdownToANSI( - fullMatch.slice( - BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH, - -BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH, - ), - baseColor, - ), - ), - ); - } else if ( - fullMatch.endsWith('**') && - fullMatch.startsWith('**') && - fullMatch.length > BOLD_MARKER_LENGTH * 2 - ) { - styledPart = chalk.bold( - parseMarkdownToANSI( - fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH), - baseColor, - ), - ); - } 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), - ) - ) { - styledPart = chalk.italic( - parseMarkdownToANSI( - fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH), - baseColor, - ), - ); - } else if ( - fullMatch.startsWith('~~') && - fullMatch.endsWith('~~') && - fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2 - ) { - styledPart = chalk.strikethrough( - parseMarkdownToANSI( - fullMatch.slice( - STRIKETHROUGH_MARKER_LENGTH, - -STRIKETHROUGH_MARKER_LENGTH, - ), - baseColor, - ), - ); - } else if ( - fullMatch.startsWith('`') && - fullMatch.endsWith('`') && - fullMatch.length > INLINE_CODE_MARKER_LENGTH - ) { - const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); - if (codeMatch && codeMatch[2]) { - styledPart = ansiColorize(codeMatch[2], theme.text.accent); - } - } else if ( - fullMatch.startsWith('[') && - fullMatch.includes('](') && - fullMatch.endsWith(')') - ) { - const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); - if (linkMatch) { - const linkText = linkMatch[1]; - const url = linkMatch[2]; - styledPart = - parseMarkdownToANSI(linkText, baseColor) + - ansiColorize(' (', baseColor) + - ansiColorize(url, theme.text.link) + - ansiColorize(')', baseColor); - } - } else if ( - fullMatch.startsWith('') && - fullMatch.endsWith('') && - fullMatch.length > - UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 - ) { - styledPart = chalk.underline( - parseMarkdownToANSI( - fullMatch.slice( - UNDERLINE_TAG_START_LENGTH, - -UNDERLINE_TAG_END_LENGTH, - ), - baseColor, - ), - ); - } else if (fullMatch.match(/^https?:\/\//)) { - styledPart = ansiColorize(fullMatch, theme.text.link); - } - } catch (e) { - debugLogger.warn('Error parsing inline markdown part:', fullMatch, e); - styledPart = ''; - } - - result += styledPart || ansiColorize(fullMatch, baseColor); - lastIndex = inlineRegex.lastIndex; - } - - if (lastIndex < text.length) { - result += ansiColorize(text.slice(lastIndex), baseColor); - } - - return result; -}; - const RenderInlineInternal: React.FC = ({ text: rawText, defaultColor, diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 143b1fe015..6143571f6a 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -17,7 +17,7 @@ import { widestLineFromStyledChars, } from 'ink'; import { theme } from '../semantic-colors.js'; -import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js'; +import { parseMarkdownToANSI } from './markdownParsingUtils.js'; import { stripUnsafeCharacters } from './textUtils.js'; interface TableRendererProps { diff --git a/packages/cli/src/ui/utils/markdownParsingUtils.test.ts b/packages/cli/src/ui/utils/markdownParsingUtils.test.ts index 05f19f09f7..a9ff96401f 100644 --- a/packages/cli/src/ui/utils/markdownParsingUtils.test.ts +++ b/packages/cli/src/ui/utils/markdownParsingUtils.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; import chalk from 'chalk'; -import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js'; +import { parseMarkdownToANSI } from './markdownParsingUtils.js'; // Mock the theme to use explicit colors instead of empty strings from the default theme. // This ensures that ansiColorize actually applies ANSI codes that we can verify. diff --git a/packages/cli/src/ui/utils/markdownParsingUtils.ts b/packages/cli/src/ui/utils/markdownParsingUtils.ts new file mode 100644 index 0000000000..10f7cb7a40 --- /dev/null +++ b/packages/cli/src/ui/utils/markdownParsingUtils.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import chalk from 'chalk'; +import { + resolveColor, + INK_SUPPORTED_NAMES, + INK_NAME_TO_HEX_MAP, +} from '../themes/color-utils.js'; +import { theme } from '../semantic-colors.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +// Constants for Markdown parsing +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 "" + +/** + * Helper to apply color to a string using ANSI escape codes, + * consistent with how Ink's colorize works. + */ +const ansiColorize = (str: string, color: string | undefined): string => { + if (!color) return str; + const resolved = resolveColor(color); + if (!resolved) return str; + + if (resolved.startsWith('#')) { + return chalk.hex(resolved)(str); + } + + const mappedHex = INK_NAME_TO_HEX_MAP[resolved]; + if (mappedHex) { + return chalk.hex(mappedHex)(str); + } + + if (INK_SUPPORTED_NAMES.has(resolved)) { + switch (resolved) { + case 'black': + return chalk.black(str); + case 'red': + return chalk.red(str); + case 'green': + return chalk.green(str); + case 'yellow': + return chalk.yellow(str); + case 'blue': + return chalk.blue(str); + case 'magenta': + return chalk.magenta(str); + case 'cyan': + return chalk.cyan(str); + case 'white': + return chalk.white(str); + case 'gray': + case 'grey': + return chalk.gray(str); + default: + return str; + } + } + + return str; +}; + +/** + * Converts markdown text into a string with ANSI escape codes. + * This mirrors the parsing logic in InlineMarkdownRenderer.tsx + */ +export const parseMarkdownToANSI = ( + text: string, + defaultColor?: string, +): string => { + const baseColor = defaultColor ?? theme.text.primary; + // Early return for plain text without markdown or URLs + if (!/[*_~`<[https?:]/.test(text)) { + return ansiColorize(text, baseColor); + } + + let result = ''; + const inlineRegex = + /(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; + let lastIndex = 0; + let match; + + while ((match = inlineRegex.exec(text)) !== null) { + if (match.index > lastIndex) { + result += ansiColorize(text.slice(lastIndex, match.index), baseColor); + } + + const fullMatch = match[0]; + let styledPart = ''; + + try { + if ( + fullMatch.endsWith('***') && + fullMatch.startsWith('***') && + fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2 + ) { + styledPart = chalk.bold( + chalk.italic( + parseMarkdownToANSI( + fullMatch.slice( + BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH, + -BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH, + ), + baseColor, + ), + ), + ); + } else if ( + fullMatch.endsWith('**') && + fullMatch.startsWith('**') && + fullMatch.length > BOLD_MARKER_LENGTH * 2 + ) { + styledPart = chalk.bold( + parseMarkdownToANSI( + fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH), + baseColor, + ), + ); + } 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), + ) + ) { + styledPart = chalk.italic( + parseMarkdownToANSI( + fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH), + baseColor, + ), + ); + } else if ( + fullMatch.startsWith('~~') && + fullMatch.endsWith('~~') && + fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2 + ) { + styledPart = chalk.strikethrough( + parseMarkdownToANSI( + fullMatch.slice( + STRIKETHROUGH_MARKER_LENGTH, + -STRIKETHROUGH_MARKER_LENGTH, + ), + baseColor, + ), + ); + } else if ( + fullMatch.startsWith('`') && + fullMatch.endsWith('`') && + fullMatch.length > INLINE_CODE_MARKER_LENGTH + ) { + const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); + if (codeMatch && codeMatch[2]) { + styledPart = ansiColorize(codeMatch[2], theme.text.accent); + } + } else if ( + fullMatch.startsWith('[') && + fullMatch.includes('](') && + fullMatch.endsWith(')') + ) { + const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); + if (linkMatch) { + const linkText = linkMatch[1]; + const url = linkMatch[2]; + styledPart = + parseMarkdownToANSI(linkText, baseColor) + + ansiColorize(' (', baseColor) + + ansiColorize(url, theme.text.link) + + ansiColorize(')', baseColor); + } + } else if ( + fullMatch.startsWith('') && + fullMatch.endsWith('') && + fullMatch.length > + UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 + ) { + styledPart = chalk.underline( + parseMarkdownToANSI( + fullMatch.slice( + UNDERLINE_TAG_START_LENGTH, + -UNDERLINE_TAG_END_LENGTH, + ), + baseColor, + ), + ); + } else if (fullMatch.match(/^https?:\/\//)) { + styledPart = ansiColorize(fullMatch, theme.text.link); + } + } catch (e) { + debugLogger.warn('Error parsing inline markdown part:', fullMatch, e); + styledPart = ''; + } + + result += styledPart || ansiColorize(fullMatch, baseColor); + lastIndex = inlineRegex.lastIndex; + } + + if (lastIndex < text.length) { + result += ansiColorize(text.slice(lastIndex), baseColor); + } + + return result; +};