|
6 | 6 |
|
7 | 7 | import React from 'react'; |
8 | 8 | import { Text } from 'ink'; |
9 | | -import chalk from 'chalk'; |
10 | | -import { |
11 | | - resolveColor, |
12 | | - INK_SUPPORTED_NAMES, |
13 | | - INK_NAME_TO_HEX_MAP, |
14 | | -} from '../themes/color-utils.js'; |
15 | | -import { theme } from '../semantic-colors.js'; |
16 | | -import { debugLogger } from '@google/gemini-cli-core'; |
| 9 | +import { parseMarkdownToANSI } from './markdownParsingUtils.js'; |
17 | 10 | import { stripUnsafeCharacters } from './textUtils.js'; |
18 | 11 |
|
19 | | -// Constants for Markdown parsing |
20 | | -const BOLD_MARKER_LENGTH = 2; // For "**" |
21 | | -const ITALIC_MARKER_LENGTH = 1; // For "*" or "_" |
22 | | -const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~") |
23 | | -const INLINE_CODE_MARKER_LENGTH = 1; // For "`" |
24 | | -const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>" |
25 | | -const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>" |
26 | | - |
27 | 12 | interface RenderInlineProps { |
28 | 13 | text: string; |
29 | 14 | defaultColor?: string; |
30 | 15 | } |
31 | 16 |
|
32 | | -/** |
33 | | - * Helper to apply color to a string using ANSI escape codes, |
34 | | - * consistent with how Ink's colorize works. |
35 | | - */ |
36 | | -const ansiColorize = (str: string, color: string | undefined): string => { |
37 | | - if (!color) return str; |
38 | | - const resolved = resolveColor(color); |
39 | | - if (!resolved) return str; |
40 | | - |
41 | | - if (resolved.startsWith('#')) { |
42 | | - return chalk.hex(resolved)(str); |
43 | | - } |
44 | | - |
45 | | - const mappedHex = INK_NAME_TO_HEX_MAP[resolved]; |
46 | | - if (mappedHex) { |
47 | | - return chalk.hex(mappedHex)(str); |
48 | | - } |
49 | | - |
50 | | - if (INK_SUPPORTED_NAMES.has(resolved)) { |
51 | | - switch (resolved) { |
52 | | - case 'black': |
53 | | - return chalk.black(str); |
54 | | - case 'red': |
55 | | - return chalk.red(str); |
56 | | - case 'green': |
57 | | - return chalk.green(str); |
58 | | - case 'yellow': |
59 | | - return chalk.yellow(str); |
60 | | - case 'blue': |
61 | | - return chalk.blue(str); |
62 | | - case 'magenta': |
63 | | - return chalk.magenta(str); |
64 | | - case 'cyan': |
65 | | - return chalk.cyan(str); |
66 | | - case 'white': |
67 | | - return chalk.white(str); |
68 | | - case 'gray': |
69 | | - case 'grey': |
70 | | - return chalk.gray(str); |
71 | | - default: |
72 | | - return str; |
73 | | - } |
74 | | - } |
75 | | - |
76 | | - return str; |
77 | | -}; |
78 | | - |
79 | | -/** |
80 | | - * Converts markdown text into a string with ANSI escape codes. |
81 | | - * This mirrors the parsing logic in InlineMarkdownRenderer.tsx |
82 | | - */ |
83 | | -export const parseMarkdownToANSI = ( |
84 | | - text: string, |
85 | | - defaultColor?: string, |
86 | | -): string => { |
87 | | - const baseColor = defaultColor ?? theme.text.primary; |
88 | | - // Early return for plain text without markdown or URLs |
89 | | - if (!/[*_~`<[https?:]/.test(text)) { |
90 | | - return ansiColorize(text, baseColor); |
91 | | - } |
92 | | - |
93 | | - let result = ''; |
94 | | - const inlineRegex = |
95 | | - /(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g; |
96 | | - let lastIndex = 0; |
97 | | - let match; |
98 | | - |
99 | | - while ((match = inlineRegex.exec(text)) !== null) { |
100 | | - if (match.index > lastIndex) { |
101 | | - result += ansiColorize(text.slice(lastIndex, match.index), baseColor); |
102 | | - } |
103 | | - |
104 | | - const fullMatch = match[0]; |
105 | | - let styledPart = ''; |
106 | | - |
107 | | - try { |
108 | | - if ( |
109 | | - fullMatch.endsWith('***') && |
110 | | - fullMatch.startsWith('***') && |
111 | | - fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2 |
112 | | - ) { |
113 | | - styledPart = chalk.bold( |
114 | | - chalk.italic( |
115 | | - parseMarkdownToANSI( |
116 | | - fullMatch.slice( |
117 | | - BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH, |
118 | | - -BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH, |
119 | | - ), |
120 | | - baseColor, |
121 | | - ), |
122 | | - ), |
123 | | - ); |
124 | | - } else if ( |
125 | | - fullMatch.endsWith('**') && |
126 | | - fullMatch.startsWith('**') && |
127 | | - fullMatch.length > BOLD_MARKER_LENGTH * 2 |
128 | | - ) { |
129 | | - styledPart = chalk.bold( |
130 | | - parseMarkdownToANSI( |
131 | | - fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH), |
132 | | - baseColor, |
133 | | - ), |
134 | | - ); |
135 | | - } else if ( |
136 | | - fullMatch.length > ITALIC_MARKER_LENGTH * 2 && |
137 | | - ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || |
138 | | - (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && |
139 | | - !/\w/.test(text.substring(match.index - 1, match.index)) && |
140 | | - !/\w/.test( |
141 | | - text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1), |
142 | | - ) && |
143 | | - !/\S[./\\]/.test(text.substring(match.index - 2, match.index)) && |
144 | | - !/[./\\]\S/.test( |
145 | | - text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2), |
146 | | - ) |
147 | | - ) { |
148 | | - styledPart = chalk.italic( |
149 | | - parseMarkdownToANSI( |
150 | | - fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH), |
151 | | - baseColor, |
152 | | - ), |
153 | | - ); |
154 | | - } else if ( |
155 | | - fullMatch.startsWith('~~') && |
156 | | - fullMatch.endsWith('~~') && |
157 | | - fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2 |
158 | | - ) { |
159 | | - styledPart = chalk.strikethrough( |
160 | | - parseMarkdownToANSI( |
161 | | - fullMatch.slice( |
162 | | - STRIKETHROUGH_MARKER_LENGTH, |
163 | | - -STRIKETHROUGH_MARKER_LENGTH, |
164 | | - ), |
165 | | - baseColor, |
166 | | - ), |
167 | | - ); |
168 | | - } else if ( |
169 | | - fullMatch.startsWith('`') && |
170 | | - fullMatch.endsWith('`') && |
171 | | - fullMatch.length > INLINE_CODE_MARKER_LENGTH |
172 | | - ) { |
173 | | - const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); |
174 | | - if (codeMatch && codeMatch[2]) { |
175 | | - styledPart = ansiColorize(codeMatch[2], theme.text.accent); |
176 | | - } |
177 | | - } else if ( |
178 | | - fullMatch.startsWith('[') && |
179 | | - fullMatch.includes('](') && |
180 | | - fullMatch.endsWith(')') |
181 | | - ) { |
182 | | - const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); |
183 | | - if (linkMatch) { |
184 | | - const linkText = linkMatch[1]; |
185 | | - const url = linkMatch[2]; |
186 | | - styledPart = |
187 | | - parseMarkdownToANSI(linkText, baseColor) + |
188 | | - ansiColorize(' (', baseColor) + |
189 | | - ansiColorize(url, theme.text.link) + |
190 | | - ansiColorize(')', baseColor); |
191 | | - } |
192 | | - } else if ( |
193 | | - fullMatch.startsWith('<u>') && |
194 | | - fullMatch.endsWith('</u>') && |
195 | | - fullMatch.length > |
196 | | - UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 |
197 | | - ) { |
198 | | - styledPart = chalk.underline( |
199 | | - parseMarkdownToANSI( |
200 | | - fullMatch.slice( |
201 | | - UNDERLINE_TAG_START_LENGTH, |
202 | | - -UNDERLINE_TAG_END_LENGTH, |
203 | | - ), |
204 | | - baseColor, |
205 | | - ), |
206 | | - ); |
207 | | - } else if (fullMatch.match(/^https?:\/\//)) { |
208 | | - styledPart = ansiColorize(fullMatch, theme.text.link); |
209 | | - } |
210 | | - } catch (e) { |
211 | | - debugLogger.warn('Error parsing inline markdown part:', fullMatch, e); |
212 | | - styledPart = ''; |
213 | | - } |
214 | | - |
215 | | - result += styledPart || ansiColorize(fullMatch, baseColor); |
216 | | - lastIndex = inlineRegex.lastIndex; |
217 | | - } |
218 | | - |
219 | | - if (lastIndex < text.length) { |
220 | | - result += ansiColorize(text.slice(lastIndex), baseColor); |
221 | | - } |
222 | | - |
223 | | - return result; |
224 | | -}; |
225 | | - |
226 | 17 | const RenderInlineInternal: React.FC<RenderInlineProps> = ({ |
227 | 18 | text: rawText, |
228 | 19 | defaultColor, |
|
0 commit comments