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